From e75f06d63f286f99c1e2054d5310c27ad2eb0659 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Thu, 14 Aug 2025 17:12:50 +0900 Subject: [PATCH 001/173] Track TabIndex value in NumericUpDown (#19348) * Track TabIndex value in NumericUpDown * Use template * Add TabNavigationProperty.OverrideDefaultValue --- .../NumericUpDown/NumericUpDown.cs | 2 ++ .../NumericUpDownTests.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs index eac1403554..90cfb41c65 100644 --- a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs +++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs @@ -379,6 +379,7 @@ namespace Avalonia.Controls FocusableProperty.OverrideDefaultValue(true); IsTabStopProperty.OverrideDefaultValue(false); + KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(KeyboardNavigationMode.Local); } /// @@ -408,6 +409,7 @@ namespace Avalonia.Controls if (TextBox != null) { TextBox.Text = Text; + TextBox[!TabIndexProperty] = this[!TabIndexProperty]; TextBox.PointerPressed += TextBoxOnPointerPressed; _textBoxTextChangedSubscription = TextBox.GetObservable(TextBox.TextProperty).Subscribe(txt => TextBoxOnTextChanged()); } diff --git a/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs b/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs index 03213cd0d1..f03bd5b633 100644 --- a/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs +++ b/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs @@ -152,5 +152,22 @@ namespace Avalonia.Controls.UnitTests }.RegisterInNameScope(scope); }); } + + [Fact] + public void TabIndex_Should_Be_Synchronized_With_Inner_TextBox() + { + RunTest((control, textbox) => + { + // Set TabIndex on NumericUpDown + control.TabIndex = 5; + + // The inner TextBox should inherit the same TabIndex + Assert.Equal(5, textbox.TabIndex); + + // Change TabIndex and verify it gets synchronized + control.TabIndex = 10; + Assert.Equal(10, textbox.TabIndex); + }); + } } } From 46d5a693f1b62e101691f965145ccec0fd01cea2 Mon Sep 17 00:00:00 2001 From: walterlv Date: Thu, 14 Aug 2025 16:44:04 +0800 Subject: [PATCH 002/173] Capture ExecutionContext for Dispatcher.InvokeAsync (#19163) * Capture ExecutionContext for Dispatcher.InvokeAsync (cherry picked from commit e41c9272ac45afc31007a8fe26f9f56291b063ef) * Implement CulturePreservingExecutionContext * Add IsFlowSuppressed checking * Add NET6_0_OR_GREATER because only the Restore need it. * Use `ExecutionContext.Run` instead of `ExecutionContext.Restore`. * Pass this to avoid lambda capture. * Use ExecutionContext directly on NET6_0_OR_GREATER * on NET6_0_OR_GREATER, use Restore so we can get a simple stack trace. * Add unit tests. * All test code must run inside Task.Run to avoid interfering with the test * First, test Task.Run to ensure that the preceding validation always passes, serving as a baseline for the subsequent Invoke/InvokeAsync tests. This way, if a later test fails, we have the .NET framework's baseline behavior for reference. --- .../CulturePreservingExecutionContext.cs | 156 +++++++++++ .../Threading/DispatcherOperation.cs | 48 +++- .../DispatcherTests.cs | 251 +++++++++++++++--- 3 files changed, 403 insertions(+), 52 deletions(-) create mode 100644 src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs diff --git a/src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs b/src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs new file mode 100644 index 0000000000..ec0ebaa4a6 --- /dev/null +++ b/src/Avalonia.Base/Threading/CulturePreservingExecutionContext.cs @@ -0,0 +1,156 @@ +#if NET6_0_OR_GREATER +// In .NET Core, the security context and call context are not supported, however, +// the impersonation context and culture would typically flow with the execution context. +// See: https://learn.microsoft.com/en-us/dotnet/api/system.threading.executioncontext +// +// So we can safely use ExecutionContext without worrying about culture flowing issues. +#else +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Threading; + +namespace Avalonia.Threading; + +/// +/// An ExecutionContext that preserves culture information across async operations. +/// This is a modernized version that removes legacy compatibility switches and +/// includes nullable reference type annotations. +/// +internal sealed class CulturePreservingExecutionContext +{ + private readonly ExecutionContext _context; + private CultureAndContext? _cultureAndContext; + + private CulturePreservingExecutionContext(ExecutionContext context) + { + _context = context; + } + + /// + /// Captures the current ExecutionContext and culture information. + /// + /// A new CulturePreservingExecutionContext instance, or null if no context needs to be captured. + public static CulturePreservingExecutionContext? Capture() + { + // ExecutionContext.SuppressFlow had been called. + // We expect ExecutionContext.Capture() to return null, so match that behavior and return null. + if (ExecutionContext.IsFlowSuppressed()) + { + return null; + } + + var context = ExecutionContext.Capture(); + if (context == null) + return null; + + return new CulturePreservingExecutionContext(context); + } + + /// + /// Runs the specified callback in the captured execution context while preserving culture information. + /// This method is used for .NET Framework and earlier .NET versions. + /// + /// The execution context to run in. + /// The callback to execute. + /// The state to pass to the callback. + public static void Run(CulturePreservingExecutionContext executionContext, ContextCallback callback, object? state) + { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (callback == null) + return; + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (executionContext == null) + ThrowNullContext(); + + // Save culture information - we will need this to restore just before + // the callback is actually invoked from CallbackWrapper. + executionContext._cultureAndContext = CultureAndContext.Initialize(callback, state); + + try + { + ExecutionContext.Run( + executionContext._context, + s_callbackWrapperDelegate, + executionContext._cultureAndContext); + } + finally + { + // Restore culture information - it might have been modified during callback execution. + executionContext._cultureAndContext.RestoreCultureInfos(); + } + } + + [DoesNotReturn] + private static void ThrowNullContext() + { + throw new InvalidOperationException("ExecutionContext cannot be null."); + } + + private static readonly ContextCallback s_callbackWrapperDelegate = CallbackWrapper; + + /// + /// Executes the callback and saves culture values immediately afterwards. + /// + /// Contains the actual callback and state. + private static void CallbackWrapper(object? obj) + { + var cultureAndContext = (CultureAndContext)obj!; + + // Restore culture information saved during Run() + cultureAndContext.RestoreCultureInfos(); + + try + { + // Execute the actual callback + cultureAndContext.Callback(cultureAndContext.State); + } + finally + { + // Save any culture changes that might have occurred during callback execution + cultureAndContext.CaptureCultureInfos(); + } + } + + /// + /// Helper class to manage culture information across execution contexts. + /// + private sealed class CultureAndContext + { + public ContextCallback Callback { get; } + public object? State { get; } + + private CultureInfo? _culture; + private CultureInfo? _uiCulture; + + private CultureAndContext(ContextCallback callback, object? state) + { + Callback = callback; + State = state; + CaptureCultureInfos(); + } + + public static CultureAndContext Initialize(ContextCallback callback, object? state) + { + return new CultureAndContext(callback, state); + } + + public void CaptureCultureInfos() + { + _culture = Thread.CurrentThread.CurrentCulture; + _uiCulture = Thread.CurrentThread.CurrentUICulture; + } + + public void RestoreCultureInfos() + { + if (_culture != null) + Thread.CurrentThread.CurrentCulture = _culture; + + if (_uiCulture != null) + Thread.CurrentThread.CurrentUICulture = _uiCulture; + } + } +} +#endif diff --git a/src/Avalonia.Base/Threading/DispatcherOperation.cs b/src/Avalonia.Base/Threading/DispatcherOperation.cs index 14b0614113..3a4513652e 100644 --- a/src/Avalonia.Base/Threading/DispatcherOperation.cs +++ b/src/Avalonia.Base/Threading/DispatcherOperation.cs @@ -5,6 +5,12 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +#if NET6_0_OR_GREATER +using ExecutionContext = System.Threading.ExecutionContext; +#else +using ExecutionContext = Avalonia.Threading.CulturePreservingExecutionContext; +#endif + namespace Avalonia.Threading; [DebuggerDisplay("{DebugDisplay}")] @@ -28,18 +34,19 @@ public class DispatcherOperation protected internal object? Callback; protected object? TaskSource; - + internal DispatcherOperation? SequentialPrev { get; set; } internal DispatcherOperation? SequentialNext { get; set; } internal DispatcherOperation? PriorityPrev { get; set; } internal DispatcherOperation? PriorityNext { get; set; } internal PriorityChain? Chain { get; set; } - + internal bool IsQueued => Chain != null; private EventHandler? _aborted; private EventHandler? _completed; private DispatcherPriority _priority; + private readonly ExecutionContext? _executionContext; internal DispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, Action callback, bool throwOnUiThread) : this(dispatcher, priority, throwOnUiThread) @@ -52,6 +59,7 @@ public class DispatcherOperation ThrowOnUiThread = throwOnUiThread; Priority = priority; Dispatcher = dispatcher; + _executionContext = ExecutionContext.Capture(); } internal string DebugDisplay @@ -103,7 +111,7 @@ public class DispatcherOperation _completed += value; } } - + remove { lock(Dispatcher.InstanceLock) @@ -112,7 +120,7 @@ public class DispatcherOperation } } } - + public bool Abort() { if (Dispatcher.Abort(this)) @@ -155,7 +163,7 @@ public class DispatcherOperation // we throw an exception instead. throw new InvalidOperationException("A thread cannot wait on operations already running on the same thread."); } - + var cts = new CancellationTokenSource(); EventHandler finishedHandler = delegate { @@ -241,7 +249,7 @@ public class DispatcherOperation } public Task GetTask() => GetTaskCore(); - + /// /// Returns an awaiter for awaiting the completion of the operation. /// @@ -259,21 +267,35 @@ public class DispatcherOperation AbortTask(); _aborted?.Invoke(this, EventArgs.Empty); } - + internal void Execute() { Debug.Assert(Status == DispatcherOperationStatus.Executing); try { using (AvaloniaSynchronizationContext.Ensure(Dispatcher, Priority)) - InvokeCore(); + { + if (_executionContext is { } executionContext) + { +#if NET6_0_OR_GREATER + ExecutionContext.Restore(executionContext); + InvokeCore(); +#else + ExecutionContext.Run(executionContext, static s => ((DispatcherOperation)s!).InvokeCore(), this); +#endif + } + else + { + InvokeCore(); + } + } } finally { _completed?.Invoke(this, EventArgs.Empty); } } - + protected virtual void InvokeCore() { try @@ -305,7 +327,7 @@ public class DispatcherOperation } internal virtual object? GetResult() => null; - + protected virtual void AbortTask() { object? taskSource; @@ -401,14 +423,14 @@ internal sealed class SendOrPostCallbackDispatcherOperation : DispatcherOperatio { private readonly object? _arg; - internal SendOrPostCallbackDispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, - SendOrPostCallback callback, object? arg, bool throwOnUiThread) + internal SendOrPostCallbackDispatcherOperation(Dispatcher dispatcher, DispatcherPriority priority, + SendOrPostCallback callback, object? arg, bool throwOnUiThread) : base(dispatcher, priority, throwOnUiThread) { Callback = callback; _arg = arg; } - + protected override void InvokeCore() { try diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index 1884a1ab65..6e3bc55e29 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -28,7 +29,7 @@ public partial class DispatcherTests public event Action Timer; public long? NextTimer { get; private set; } public bool AskedForSignal { get; private set; } - + public void UpdateTimer(long? dueTimeInTicks) { NextTimer = dueTimeInTicks; @@ -79,16 +80,16 @@ public partial class DispatcherTests ReadyForBackgroundProcessing?.Invoke(); } } - + class SimpleControlledDispatcherImpl : SimpleDispatcherWithBackgroundProcessingImpl, IControlledDispatcherImpl { private readonly bool _useTestTimeout = true; private readonly CancellationToken? _cancel; public int RunLoopCount { get; private set; } - + public SimpleControlledDispatcherImpl() { - + } public SimpleControlledDispatcherImpl(CancellationToken cancel, bool useTestTimeout = false) @@ -96,7 +97,7 @@ public partial class DispatcherTests _useTestTimeout = useTestTimeout; _cancel = cancel; } - + public void RunLoop(CancellationToken token) { RunLoopCount++; @@ -114,8 +115,8 @@ public partial class DispatcherTests } - - + + [Fact] public void DispatcherExecutesJobsAccordingToPriority() { @@ -129,7 +130,7 @@ public partial class DispatcherTests impl.ExecuteSignal(); Assert.Equal(new[] { "Render", "Input", "Background" }, actions); } - + [Fact] public void DispatcherPreservesOrderWhenChangingPriority() { @@ -139,13 +140,13 @@ public partial class DispatcherTests var toPromote = disp.InvokeAsync(()=>actions.Add("PromotedRender"), DispatcherPriority.Background); var toPromote2 = disp.InvokeAsync(()=>actions.Add("PromotedRender2"), DispatcherPriority.Input); disp.Post(() => actions.Add("Render"), DispatcherPriority.Render); - + toPromote.Priority = DispatcherPriority.Render; toPromote2.Priority = DispatcherPriority.Render; - + Assert.True(impl.AskedForSignal); impl.ExecuteSignal(); - + Assert.Equal(new[] { "PromotedRender", "PromotedRender2", "Render" }, actions); } @@ -178,7 +179,7 @@ public partial class DispatcherTests var expectedCount = (c + 1) * 3; if (c == 3) expectedCount = 10; - + Assert.Equal(Enumerable.Range(0, expectedCount), actions); Assert.False(impl.AskedForSignal); if (c < 3) @@ -189,8 +190,8 @@ public partial class DispatcherTests Assert.Null(impl.NextTimer); } } - - + + [Fact] public void DispatcherStopsItemProcessingWhenInputIsPending() { @@ -225,7 +226,7 @@ public partial class DispatcherTests 3 => 10, _ => throw new InvalidOperationException($"Unexpected value {c}") }; - + Assert.Equal(Enumerable.Range(0, expectedCount), actions); Assert.False(impl.AskedForSignal); if (c < 3) @@ -255,7 +256,7 @@ public partial class DispatcherTests foreground ? DispatcherPriority.Default : DispatcherPriority.Background).Wait(); Assert.True(finished); - if (controlled) + if (controlled) Assert.Equal(foreground ? 0 : 1, ((SimpleControlledDispatcherImpl)impl).RunLoopCount); } @@ -271,7 +272,7 @@ public partial class DispatcherTests Dispatcher.ResetForUnitTests(); SynchronizationContext.SetSynchronizationContext(null); } - + public void Dispose() { Dispatcher.ResetForUnitTests(); @@ -279,7 +280,7 @@ public partial class DispatcherTests SynchronizationContext.SetSynchronizationContext(null); } } - + [Fact] public void ExitAllFramesShouldExitAllFramesAndBeAbleToContinue() { @@ -301,10 +302,10 @@ public partial class DispatcherTests disp.MainLoop(CancellationToken.None); - + Assert.Equal(new[] { "Nested frame", "ExitAllFrames", "Nested frame exited" }, actions); actions.Clear(); - + var secondLoop = new CancellationTokenSource(); disp.Post(() => { @@ -315,8 +316,8 @@ public partial class DispatcherTests Assert.Equal(new[] { "Callback after exit" }, actions); } } - - + + [Fact] public void ShutdownShouldExitAllFramesAndNotAllowNewFrames() { @@ -335,7 +336,7 @@ public partial class DispatcherTests actions.Add("Shutdown"); disp.BeginInvokeShutdown(DispatcherPriority.Normal); }); - + disp.Post(() => { actions.Add("Nested frame after shutdown"); @@ -343,12 +344,12 @@ public partial class DispatcherTests Dispatcher.UIThread.MainLoop(CancellationToken.None); actions.Add("Nested frame after shutdown exited"); }); - + var criticalFrameAfterShutdown = new DispatcherFrame(false); disp.Post(() => { actions.Add("Critical frame after shutdown"); - + Dispatcher.UIThread.PushFrame(criticalFrameAfterShutdown); actions.Add("Critical frame after shutdown exited"); }); @@ -362,7 +363,7 @@ public partial class DispatcherTests Assert.Equal(new[] { - "Nested frame", + "Nested frame", "Shutdown", // Normal nested frames are supposed to exit immediately "Nested frame after shutdown", "Nested frame after shutdown exited", @@ -372,7 +373,7 @@ public partial class DispatcherTests "Nested frame exited" }, actions); actions.Clear(); - + disp.Post(()=>actions.Add("Frame after shutdown finished")); Assert.Throws(() => disp.MainLoop(CancellationToken.None)); Assert.Empty(actions); @@ -388,7 +389,7 @@ public partial class DispatcherTests return base.Wait(waitHandles, waitAll, millisecondsTimeout); } } - + [Fact] public void DisableProcessingShouldStopProcessing() { @@ -407,7 +408,7 @@ public partial class DispatcherTests SynchronizationContext.SetSynchronizationContext(avaloniaContext); var waitHandle = new ManualResetEvent(true); - + helper.WaitCount = 0; waitHandle.WaitOne(100); Assert.Equal(0, helper.WaitCount); @@ -431,8 +432,8 @@ public partial class DispatcherTests void DumpCurrentPriority() => priorities.Add(((AvaloniaSynchronizationContext)SynchronizationContext.Current!).Priority); - - + + disp.Post(DumpCurrentPriority, DispatcherPriority.Normal); disp.Post(DumpCurrentPriority, DispatcherPriority.Loaded); disp.Post(DumpCurrentPriority, DispatcherPriority.Input); @@ -467,34 +468,34 @@ public partial class DispatcherTests public void DispatcherInvokeAsyncUnwrapsTasks() { int asyncMethodStage = 0; - + async Task AsyncMethod() { asyncMethodStage = 1; await Task.Delay(200); asyncMethodStage = 2; } - + async Task AsyncMethodWithResult() { await Task.Delay(100); return 1; } - + async Task Test() { await Dispatcher.UIThread.InvokeAsync(AsyncMethod); Assert.Equal(2, asyncMethodStage); Assert.Equal(1, await Dispatcher.UIThread.InvokeAsync(AsyncMethodWithResult)); asyncMethodStage = 0; - + await Dispatcher.UIThread.InvokeAsync(AsyncMethod, DispatcherPriority.Default); Assert.Equal(2, asyncMethodStage); Assert.Equal(1, await Dispatcher.UIThread.InvokeAsync(AsyncMethodWithResult, DispatcherPriority.Default)); - + Dispatcher.UIThread.ExitAllFrames(); } - + using (new DispatcherServices(new ManagedDispatcherImpl(null))) { var t = Test(); @@ -505,8 +506,8 @@ public partial class DispatcherTests t.GetAwaiter().GetResult(); } } - - + + [Fact] public async Task DispatcherResumeContinuesOnUIThread() { @@ -605,4 +606,176 @@ public partial class DispatcherTests Dispatcher.UIThread.MainLoop(tokenSource.Token); } + +#nullable enable + private class AsyncLocalTestClass + { + public AsyncLocal AsyncLocalField { get; set; } = new AsyncLocal(); + } + + [Fact] + public async Task ExecutionContextIsPreservedInDispatcherInvokeAsync() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + var tokenSource = new CancellationTokenSource(); + string? test1 = null; + string? test2 = null; + string? test3 = null; + + // All test code must run inside Task.Run to avoid interfering with the test: + // 1. Prevent the execution context from being captured by MainLoop. + // 2. Prevent the execution context from remaining effective when set on the same thread. + var task = Task.Run(() => + { + var testObject = new AsyncLocalTestClass(); + + // Test 1: Verify Task.Run preserves the execution context. + // First, test Task.Run to ensure that the preceding validation always passes, serving as a baseline for the subsequent Invoke/InvokeAsync tests. + // This way, if a later test fails, we have the .NET framework's baseline behavior for reference. + testObject.AsyncLocalField.Value = "Initial Value"; + var task1 = Task.Run(() => + { + test1 = testObject.AsyncLocalField.Value; + }); + + // Test 2: Verify Invoke preserves the execution context. + testObject.AsyncLocalField.Value = "Initial Value"; + Dispatcher.UIThread.Invoke(() => + { + test2 = testObject.AsyncLocalField.Value; + }); + + // Test 3: Verify InvokeAsync preserves the execution context. + testObject.AsyncLocalField.Value = "Initial Value"; + _ = Dispatcher.UIThread.InvokeAsync(() => + { + test3 = testObject.AsyncLocalField.Value; + }); + + _ = Dispatcher.UIThread.InvokeAsync(async () => + { + await Task.WhenAll(task1); + tokenSource.Cancel(); + }); + + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + await Task.WhenAll(task); + + // Assertions + // Invoke(): Always passes because the context is not changed. + Assert.Equal("Initial Value", test1); + // Task.Run: Always passes (guaranteed by the .NET runtime). + Assert.Equal("Initial Value", test2); + // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 + Assert.Equal("Initial Value", test3); + } + + [Fact] + public async Task ExecutionContextIsNotPreservedAmongDispatcherInvokeAsync() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + var tokenSource = new CancellationTokenSource(); + string? test = null; + + // All test code must run inside Task.Run to avoid interfering with the test: + // 1. Prevent the execution context from being captured by MainLoop. + // 2. Prevent the execution context from remaining effective when set on the same thread. + var task = Task.Run(() => + { + var testObject = new AsyncLocalTestClass(); + + // Test: Verify that InvokeAsync calls do not share execution context between each other. + _ = Dispatcher.UIThread.InvokeAsync(() => + { + testObject.AsyncLocalField.Value = "Initial Value"; + }); + _ = Dispatcher.UIThread.InvokeAsync(() => + { + test = testObject.AsyncLocalField.Value; + }); + + _ = Dispatcher.UIThread.InvokeAsync(() => + { + tokenSource.Cancel(); + }); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + await Task.WhenAll(task); + + // Assertions + // The value should NOT flow between different InvokeAsync execution contexts. + Assert.Null(test); + } + + [Fact] + public async Task ExecutionContextCultureInfoIsPreservedInDispatcherInvokeAsync() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + var tokenSource = new CancellationTokenSource(); + string? test1 = null; + string? test2 = null; + string? test3 = null; + var oldCulture = Thread.CurrentThread.CurrentCulture; + + // All test code must run inside Task.Run to avoid interfering with the test: + // 1. Prevent the execution context from being captured by MainLoop. + // 2. Prevent the execution context from remaining effective when set on the same thread. + var task = Task.Run(() => + { + // This culture tag is Sumerian and is extremely unlikely to be set as the default on any device, + // ensuring that this test will not be affected by the user's environment. + Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("sux-Shaw-UM"); + + // Test 1: Verify Task.Run preserves the culture in the execution context. + // First, test Task.Run to ensure that the preceding validation always passes, serving as a baseline for the subsequent Invoke/InvokeAsync tests. + // This way, if a later test fails, we have the .NET framework's baseline behavior for reference. + var task1 = Task.Run(() => + { + test1 = Thread.CurrentThread.CurrentCulture.Name; + }); + + // Test 2: Verify Invoke preserves the execution context. + Dispatcher.UIThread.Invoke(() => + { + test2 = Thread.CurrentThread.CurrentCulture.Name; + }); + + // Test 3: Verify InvokeAsync preserves the culture in the execution context. + _ = Dispatcher.UIThread.InvokeAsync(() => + { + test3 = Thread.CurrentThread.CurrentCulture.Name; + }); + + _ = Dispatcher.UIThread.InvokeAsync(async () => + { + await Task.WhenAll(task1); + tokenSource.Cancel(); + }); + }); + + try + { + Dispatcher.UIThread.MainLoop(tokenSource.Token); + await Task.WhenAll(task); + + // Assertions + // Invoke(): Always passes because the context is not changed. + Assert.Equal("sux-Shaw-UM", test1); + // Task.Run: Always passes (guaranteed by the .NET runtime). + Assert.Equal("sux-Shaw-UM", test2); + // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 + Assert.Equal("sux-Shaw-UM", test3); + } + finally + { + Thread.CurrentThread.CurrentCulture = oldCulture; + // Ensure that this test does not have a negative impact on other tests. + Assert.NotEqual("sux-Shaw-UM", oldCulture.Name); + } + } +#nullable restore + } From 34e6d1457709a7f1b8626d2699316f65fbc13541 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Thu, 14 Aug 2025 10:58:58 +0200 Subject: [PATCH 003/173] Remove non implemented message AvnView.resetPressedMouseButtons (#19445) --- native/Avalonia.Native/src/OSX/AvnView.h | 1 - native/Avalonia.Native/src/OSX/WindowBaseImpl.mm | 1 - 2 files changed, 2 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/AvnView.h b/native/Avalonia.Native/src/OSX/AvnView.h index c80805a15c..030330c908 100644 --- a/native/Avalonia.Native/src/OSX/AvnView.h +++ b/native/Avalonia.Native/src/OSX/AvnView.h @@ -19,7 +19,6 @@ -(AvnPoint) translateLocalPoint:(AvnPoint)pt; -(void) onClosed; -(void) setModifiers:(NSEventModifierFlags)modifierFlags; --(void) resetPressedMouseButtons; -(AvnPlatformResizeReason) getResizeReason; -(void) setResizeReason:(AvnPlatformResizeReason)reason; diff --git a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm index 6553e8f460..07ee404d0f 100644 --- a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm +++ b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm @@ -450,7 +450,6 @@ HRESULT WindowBaseImpl::BeginDragAndDropOperation(AvnDragDropEffects effects, Av op |= NSDragOperationLink; if ((ieffects & (int) AvnDragDropEffects::Move) != 0) op |= NSDragOperationMove; - [View resetPressedMouseButtons]; [View beginDraggingSessionWithItems:@[dragItem] event:nsevent source:CreateDraggingSource((NSDragOperation) op, cb, sourceHandle)]; return S_OK; From cb79e5d7bb1bb7f3dd1cebf4a3dd6b0bc76f5a4b Mon Sep 17 00:00:00 2001 From: walterlv Date: Fri, 15 Aug 2025 15:31:26 +0800 Subject: [PATCH 004/173] Fix comment for #19163 (#19469) --- tests/Avalonia.Base.UnitTests/DispatcherTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index 6e3bc55e29..92871db682 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -664,9 +664,9 @@ public partial class DispatcherTests await Task.WhenAll(task); // Assertions - // Invoke(): Always passes because the context is not changed. - Assert.Equal("Initial Value", test1); // Task.Run: Always passes (guaranteed by the .NET runtime). + Assert.Equal("Initial Value", test1); + // Invoke: Always passes because the context is not changed. Assert.Equal("Initial Value", test2); // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 Assert.Equal("Initial Value", test3); @@ -762,9 +762,9 @@ public partial class DispatcherTests await Task.WhenAll(task); // Assertions - // Invoke(): Always passes because the context is not changed. - Assert.Equal("sux-Shaw-UM", test1); // Task.Run: Always passes (guaranteed by the .NET runtime). + Assert.Equal("sux-Shaw-UM", test1); + // Invoke: Always passes because the context is not changed. Assert.Equal("sux-Shaw-UM", test2); // InvokeAsync: See https://github.com/AvaloniaUI/Avalonia/pull/19163 Assert.Equal("sux-Shaw-UM", test3); From 4f666f0ba1432555a0e41a438cabe42be3aeed47 Mon Sep 17 00:00:00 2001 From: Vladislav Pozdniakov Date: Fri, 15 Aug 2025 12:21:01 +0100 Subject: [PATCH 005/173] Fix macOS thick titlebar mouse event duplication (#19447) * Added failing test for OSXThickTitleBar single title area click produce double click #19320 * Fixed OSXThickTitleBar title area click duplication and event delays until the event-tracking-loop is completed * IntegrationTestApp. Move event counter controls to a separate column. Fixes Changing_Size_Should_Not_Change_Position test * Move pointer tests to Default group to avoid interference * Try to fix CI crash * Try disabling test * Fix CI test. Collection back to Default * CI fix. Return back empty test. * CI fix. Minimal bug test * CI test. Add double click test * CI fix. Remove double click test. --------- Co-authored-by: Julien Lebosquain --- native/Avalonia.Native/src/OSX/AvnWindow.mm | 98 +++++++------------ .../IntegrationTestApp/ShowWindowTest.axaml | 18 ++-- .../ShowWindowTest.axaml.cs | 27 +++++ .../ElementExtensions.cs | 5 + .../PointerTests_MacOS.cs | 53 +++++++++- 5 files changed, 134 insertions(+), 67 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm index 03daa2f296..79fe095731 100644 --- a/native/Avalonia.Native/src/OSX/AvnWindow.mm +++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm @@ -35,6 +35,7 @@ bool _canBecomeKeyWindow; bool _isExtended; bool _isTransitioningToFullScreen; + bool _isTitlebarSession; AvnMenu* _menu; IAvnAutomationPeer* _automationPeer; AvnAutomationNode* _automationNode; @@ -501,68 +502,10 @@ return NO; } -- (void)forwardToAvnView:(NSEvent *)event -{ - auto parent = _parent.tryGetWithCast(); - if (!parent) { - return; - } - - switch(event.type) { - case NSEventTypeLeftMouseDown: - [parent->View mouseDown:event]; - break; - case NSEventTypeLeftMouseUp: - [parent->View mouseUp:event]; - break; - case NSEventTypeLeftMouseDragged: - [parent->View mouseDragged:event]; - break; - case NSEventTypeRightMouseDown: - [parent->View rightMouseDown:event]; - break; - case NSEventTypeRightMouseUp: - [parent->View rightMouseUp:event]; - break; - case NSEventTypeRightMouseDragged: - [parent->View rightMouseDragged:event]; - break; - case NSEventTypeOtherMouseDown: - [parent->View otherMouseDown:event]; - break; - case NSEventTypeOtherMouseUp: - [parent->View otherMouseUp:event]; - break; - case NSEventTypeOtherMouseDragged: - [parent->View otherMouseDragged:event]; - break; - case NSEventTypeMouseMoved: - [parent->View mouseMoved:event]; - break; - default: - break; - } -} - - (void)sendEvent:(NSEvent *_Nonnull)event { - // Event-tracking loop for thick titlebar mouse events - if (event.type == NSEventTypeLeftMouseDown && [self isPointInTitlebar:event.locationInWindow]) - { - NSEventMask mask = NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp; - NSEvent *ev = event; - while (ev.type != NSEventTypeLeftMouseUp) - { - [self forwardToAvnView:ev]; - [super sendEvent:ev]; - ev = [NSApp nextEventMatchingMask:mask - untilDate:[NSDate distantFuture] - inMode:NSEventTrackingRunLoopMode - dequeue:YES]; - } - [self forwardToAvnView:ev]; - [super sendEvent:ev]; - return; + if (event.type == NSEventTypeLeftMouseDown) { + _isTitlebarSession = [self isPointInTitlebar:event.locationInWindow]; } [super sendEvent:event]; @@ -603,6 +546,37 @@ } break; + case NSEventTypeLeftMouseDragged: + case NSEventTypeMouseMoved: + case NSEventTypeLeftMouseUp: + { + // Usually NSToolbar events are passed natively to AvnView when the mouse is inside the control. + // When a drag operation started in NSToolbar leaves the control region, the view does not get any + // events. We will detect this scenario and pass events ourselves. + + if(!_isTitlebarSession || [self isPointInTitlebar:event.locationInWindow]) + break; + + AvnView* view = parent->View; + + if(!view) + break; + + if(event.type == NSEventTypeLeftMouseDragged) + { + [view mouseDragged:event]; + } + else if(event.type == NSEventTypeMouseMoved) + { + [view mouseMoved:event]; + } + else if(event.type == NSEventTypeLeftMouseUp) + { + [view mouseUp:event]; + } + } + break; + case NSEventTypeMouseEntered: { parent->UpdateCursor(); @@ -618,6 +592,10 @@ default: break; } + + if(event.type == NSEventTypeLeftMouseUp) { + _isTitlebarSession = NO; + } } } diff --git a/samples/IntegrationTestApp/ShowWindowTest.axaml b/samples/IntegrationTestApp/ShowWindowTest.axaml index c43e00497f..272c61ed0c 100644 --- a/samples/IntegrationTestApp/ShowWindowTest.axaml +++ b/samples/IntegrationTestApp/ShowWindowTest.axaml @@ -15,7 +15,7 @@ - + @@ -62,13 +62,19 @@