diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index f426a20b2c..f5812f71ff 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -697,13 +697,18 @@ namespace Avalonia.Media.TextFormatting i = lastRunIndex; + //Possible overlap at runs of different direction if (directionalWidth == 0) { - continue; + //In case a run only contains a linebreak we don't want to skip it. + if (currentRun is ShapedTextRun shaped && currentRun.Length - shaped.GlyphRun.Metrics.NewLineLength > 0) + { + continue; + } } - var coveredLength = 0; - TextBounds? textBounds = null; + int coveredLength; + TextBounds? textBounds; switch (currentDirection) { @@ -831,14 +836,18 @@ namespace Avalonia.Media.TextFormatting i = firstRunIndex; + //Possible overlap at runs of different direction if (directionalWidth == 0) { - continue; + //In case a run only contains a linebreak we don't want to skip it. + if (currentRun is ShapedTextRun shaped && currentRun.Length - shaped.GlyphRun.Metrics.NewLineLength > 0) + { + continue; + } } - var coveredLength = 0; - TextBounds? textBounds = null; + int coveredLength; switch (currentDirection) { diff --git a/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs b/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs index 1b29cf32f7..e0563876bf 100644 --- a/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs +++ b/src/Avalonia.Base/Threading/AvaloniaSynchronizationContext.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.ConstrainedExecution; using System.Threading; +using Avalonia.Utilities; namespace Avalonia.Threading { @@ -9,6 +10,28 @@ namespace Avalonia.Threading /// public class AvaloniaSynchronizationContext : SynchronizationContext { + internal readonly DispatcherPriority Priority; + private readonly NonPumpingLockHelper.IHelperImpl? _nonPumpingHelper = + AvaloniaLocator.Current.GetService(); + + public AvaloniaSynchronizationContext(): this(Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) + { + + } + + // This constructor is here to enforce STA behavior for unit tests + internal AvaloniaSynchronizationContext(bool isStaThread) + { + if (_nonPumpingHelper != null + && isStaThread) + SetWaitNotificationRequired(); + } + + public AvaloniaSynchronizationContext(DispatcherPriority priority) + { + Priority = priority; + } + /// /// Controls if SynchronizationContext should be installed in InstallIfNeeded. Used by Designer. /// @@ -24,13 +47,13 @@ namespace Avalonia.Threading return; } - SetSynchronizationContext(new AvaloniaSynchronizationContext()); + SetSynchronizationContext(Dispatcher.UIThread.GetContextWithPriority(DispatcherPriority.Normal)); } /// public override void Post(SendOrPostCallback d, object? state) { - Dispatcher.UIThread.Post(d, state, DispatcherPriority.Background); + Dispatcher.UIThread.Post(d, state, Priority); } /// @@ -41,7 +64,50 @@ namespace Avalonia.Threading else Dispatcher.UIThread.InvokeAsync(() => d(state), DispatcherPriority.Send).GetAwaiter().GetResult(); } + +#if !NET6_0_OR_GREATER + [PrePrepareMethod] +#endif + public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) + { + if ( + _nonPumpingHelper != null + && Dispatcher.UIThread.CheckAccess() + && Dispatcher.UIThread.DisabledProcessingCount > 0) + return _nonPumpingHelper.Wait(waitHandles, waitAll, millisecondsTimeout); + return base.Wait(waitHandles, waitAll, millisecondsTimeout); + } + public record struct RestoreContext : IDisposable + { + private readonly SynchronizationContext? _oldContext; + private bool _needRestore; + internal RestoreContext(SynchronizationContext? oldContext) + { + _oldContext = oldContext; + _needRestore = true; + } + + public void Dispose() + { + if (_needRestore) + { + SetSynchronizationContext(_oldContext); + _needRestore = false; + } + } + } + + public static RestoreContext Ensure(DispatcherPriority priority) + { + if (Current is AvaloniaSynchronizationContext avaloniaContext + && avaloniaContext.Priority == priority) + return default; + var oldContext = Current; + Dispatcher.UIThread.VerifyAccess(); + SetSynchronizationContext(Dispatcher.UIThread.GetContextWithPriority(priority)); + return new RestoreContext(oldContext); + } } } diff --git a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs index 80b62b3818..c6e9203f70 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel; using System.Diagnostics; using System.Threading; +using System.Threading.Tasks; namespace Avalonia.Threading; @@ -106,7 +107,8 @@ public partial class Dispatcher // call the callback directly. if (!cancellationToken.IsCancellationRequested && priority == DispatcherPriority.Send && CheckAccess()) { - callback(); + using (AvaloniaSynchronizationContext.Ensure(priority)) + callback(); return; } @@ -227,7 +229,8 @@ public partial class Dispatcher // call the callback directly. if (!cancellationToken.IsCancellationRequested && priority == DispatcherPriority.Send && CheckAccess()) { - return callback(); + using (AvaloniaSynchronizationContext.Ensure(priority)) + return callback(); } // Slow-Path: go through the queue. @@ -388,7 +391,7 @@ public partial class Dispatcher return operation; } - private void InvokeAsyncImpl(DispatcherOperation operation, CancellationToken cancellationToken) + internal void InvokeAsyncImpl(DispatcherOperation operation, CancellationToken cancellationToken) { bool succeeded = false; @@ -482,7 +485,7 @@ public partial class Dispatcher // invoke. try { - operation.GetTask().Wait(); + operation.Wait(); Debug.Assert(operation.Status == DispatcherOperationStatus.Completed || operation.Status == DispatcherOperationStatus.Aborted); @@ -539,6 +542,48 @@ public partial class Dispatcher InvokeAsyncImpl(new DispatcherOperation(this, priority, action, true), CancellationToken.None); } + /// + /// Executes the specified Func asynchronously on the + /// thread that the Dispatcher was created on + /// + /// + /// A Func delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// An task that completes after the task returned from callback finishes + /// + public Task InvokeTaskAsync(Func callback, DispatcherPriority priority = default) + { + _ = callback ?? throw new ArgumentNullException(nameof(callback)); + return InvokeAsync(callback, priority).GetTask().Unwrap(); + } + + /// + /// Executes the specified Func> asynchronously on the + /// thread that the Dispatcher was created on + /// + /// + /// A Func> delegate to invoke through the dispatcher. + /// + /// + /// The priority that determines in what order the specified + /// callback is invoked relative to the other pending operations + /// in the Dispatcher. + /// + /// + /// An task that completes after the task returned from callback finishes + /// + public Task InvokeTaskAsync(Func> action, DispatcherPriority priority = default) + { + _ = action ?? throw new ArgumentNullException(nameof(action)); + return InvokeAsync(action, priority).GetTask().Unwrap(); + } + /// /// Posts an action that will be invoked on the dispatcher thread. /// diff --git a/src/Avalonia.Base/Threading/Dispatcher.MainLoop.cs b/src/Avalonia.Base/Threading/Dispatcher.MainLoop.cs new file mode 100644 index 0000000000..e1833fef2b --- /dev/null +++ b/src/Avalonia.Base/Threading/Dispatcher.MainLoop.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Avalonia.Utilities; + +namespace Avalonia.Threading; + +public partial class Dispatcher +{ + internal bool ExitAllFramesRequested { get; private set; } + internal bool HasShutdownStarted { get; private set; } + internal int DisabledProcessingCount { get; set; } + private bool _hasShutdownFinished; + private bool _startingShutdown; + + private Stack _frames = new(); + + + /// + /// Raised when the dispatcher is shutting down. + /// + public event EventHandler? ShutdownStarted; + + /// + /// Raised when the dispatcher is shut down. + /// + public event EventHandler? ShutdownFinished; + + /// + /// Push an execution frame. + /// + /// + /// The frame for the dispatcher to process. + /// + public void PushFrame(DispatcherFrame frame) + { + VerifyAccess(); + if (_controlledImpl == null) + throw new PlatformNotSupportedException(); + _ = frame ?? throw new ArgumentNullException(nameof(frame)); + + if(_hasShutdownFinished) // Dispatcher thread - no lock needed for read + throw new InvalidOperationException("Cannot perform requested operation because the Dispatcher shut down"); + + if (DisabledProcessingCount > 0) + throw new InvalidOperationException( + "Cannot perform this operation while dispatcher processing is suspended."); + + try + { + _frames.Push(frame); + using (AvaloniaSynchronizationContext.Ensure(DispatcherPriority.Normal)) + frame.Run(_controlledImpl); + } + finally + { + _frames.Pop(); + if (_frames.Count == 0) + { + if (HasShutdownStarted) + ShutdownImpl(); + else + ExitAllFramesRequested = false; + } + } + } + + /// + /// Runs the dispatcher's main loop. + /// + /// + /// A cancellation token used to exit the main loop. + /// + public void MainLoop(CancellationToken cancellationToken) + { + if (_controlledImpl == null) + throw new PlatformNotSupportedException(); + var frame = new DispatcherFrame(); + cancellationToken.Register(() => frame.Continue = false); + PushFrame(frame); + } + + /// + /// Requests that all nested frames exit. + /// + public void ExitAllFrames() + { + if (_frames.Count == 0) + return; + ExitAllFramesRequested = true; + foreach (var f in _frames) + f.MaybeExitOnDispatcherRequest(); + } + + /// + /// Begins the process of shutting down the dispatcher. + /// + public void BeginInvokeShutdown(DispatcherPriority priority) => Post(StartShutdownImpl, priority); + + + /// + /// Initiates the shutdown process of the Dispatcher synchronously. + /// + public void InvokeShutdown() => Invoke(StartShutdownImpl, DispatcherPriority.Send); + + private void StartShutdownImpl() + { + if (!_startingShutdown) + { + // We only need this to prevent reentrancy if the ShutdownStarted event + // tries to shut down again. + _startingShutdown = true; + + // Call the ShutdownStarted event before we actually mark ourselves + // as shutting down. This is so the handlers can actually do work + // when they get this event without throwing exceptions. + ShutdownStarted?.Invoke(this, EventArgs.Empty); + + HasShutdownStarted = true; + + if (_frames.Count > 0) + ExitAllFrames(); + else ShutdownImpl(); + } + } + + + private void ShutdownImpl() + { + DispatcherOperation? operation = null; + _impl.Timer -= PromoteTimers; + _impl.Signaled -= Signaled; + do + { + lock (InstanceLock) + { + if (_queue.MaxPriority != DispatcherPriority.Invalid) + { + operation = _queue.Peek(); + } + else + { + operation = null; + } + } + + if (operation != null) + { + operation.Abort(); + } + } while (operation != null); + + _impl.UpdateTimer(null); + _hasShutdownFinished = true; + ShutdownFinished?.Invoke(this, EventArgs.Empty); + } + + public record struct DispatcherProcessingDisabled : IDisposable + { + private readonly SynchronizationContext? _oldContext; + + private readonly bool _restoreContext; + private Dispatcher? _dispatcher; + + internal DispatcherProcessingDisabled(Dispatcher dispatcher) + { + _dispatcher = dispatcher; + } + + internal DispatcherProcessingDisabled(Dispatcher dispatcher, SynchronizationContext? oldContext) : this( + dispatcher) + { + _oldContext = oldContext; + _restoreContext = true; + } + + public void Dispose() + { + if(_dispatcher==null) + return; + _dispatcher.DisabledProcessingCount--; + _dispatcher = null; + if (_restoreContext) + SynchronizationContext.SetSynchronizationContext(_oldContext); + } + } + + /// + /// Disable the event processing of the dispatcher. + /// + /// + /// This is an advanced method intended to eliminate the chance of + /// unrelated reentrancy. The effect of disabling processing is: + /// 1) CLR locks will not pump messages internally. + /// 2) No one is allowed to push a frame. + /// 3) No message processing is permitted. + /// + public DispatcherProcessingDisabled DisableProcessing() + { + VerifyAccess(); + + // Turn off processing. + DisabledProcessingCount++; + var oldContext = SynchronizationContext.Current; + if (oldContext is AvaloniaSynchronizationContext or NonPumpingSyncContext) + return new DispatcherProcessingDisabled(this); + + var helper = AvaloniaLocator.Current.GetService(); + if (helper == null) + return new DispatcherProcessingDisabled(this); + + SynchronizationContext.SetSynchronizationContext(new NonPumpingSyncContext(helper, oldContext)); + return new DispatcherProcessingDisabled(this, oldContext); + + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Threading/Dispatcher.Queue.cs b/src/Avalonia.Base/Threading/Dispatcher.Queue.cs index c91af1a514..78a0740e56 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Queue.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Queue.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Threading; namespace Avalonia.Threading; @@ -43,10 +44,19 @@ public partial class Dispatcher /// public void RunJobs(DispatcherPriority? priority = null) { + RunJobs(priority, CancellationToken.None); + } + + internal void RunJobs(DispatcherPriority? priority, CancellationToken cancellationToken) + { + if (DisabledProcessingCount > 0) + throw new InvalidOperationException( + "Cannot perform this operation while dispatcher processing is suspended."); + priority ??= DispatcherPriority.MinimumActiveValue; if (priority < DispatcherPriority.MinimumActiveValue) priority = DispatcherPriority.MinimumActiveValue; - while (true) + while (!cancellationToken.IsCancellationRequested) { DispatcherOperation? job; lock (InstanceLock) @@ -168,7 +178,7 @@ public partial class Dispatcher } } - private bool RequestProcessing() + internal bool RequestProcessing() { lock (InstanceLock) { diff --git a/src/Avalonia.Base/Threading/Dispatcher.cs b/src/Avalonia.Base/Threading/Dispatcher.cs index 25a4a4ce2c..f257072dc8 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.cs @@ -18,11 +18,13 @@ public partial class Dispatcher : IDispatcher { private IDispatcherImpl _impl; internal object InstanceLock { get; } = new(); - private bool _hasShutdownFinished; private IControlledDispatcherImpl? _controlledImpl; private static Dispatcher? s_uiThread; private IDispatcherImplWithPendingInput? _pendingInputImpl; - private IDispatcherImplWithExplicitBackgroundProcessing? _backgroundProcessingImpl; + private readonly IDispatcherImplWithExplicitBackgroundProcessing? _backgroundProcessingImpl; + + private readonly AvaloniaSynchronizationContext?[] _priorityContexts = + new AvaloniaSynchronizationContext?[DispatcherPriority.MaxValue - DispatcherPriority.MinValue + 1]; internal Dispatcher(IDispatcherImpl impl) { @@ -37,6 +39,7 @@ public partial class Dispatcher : IDispatcher } public static Dispatcher UIThread => s_uiThread ??= CreateUIThreadDispatcher(); + public bool SupportsRunLoops => _controlledImpl != null; private static Dispatcher CreateUIThreadDispatcher() { @@ -72,50 +75,14 @@ public partial class Dispatcher : IDispatcher [MethodImpl(MethodImplOptions.NoInlining)] static void ThrowVerifyAccess() => throw new InvalidOperationException("Call from invalid thread"); - ThrowVerifyAccess(); } } - internal void Shutdown() - { - DispatcherOperation? operation = null; - _impl.Timer -= PromoteTimers; - _impl.Signaled -= Signaled; - do - { - lock(InstanceLock) - { - if(_queue.MaxPriority != DispatcherPriority.Invalid) - { - operation = _queue.Peek(); - } - else - { - operation = null; - } - } - - if(operation != null) - { - operation.Abort(); - } - } while(operation != null); - _impl.UpdateTimer(null); - _hasShutdownFinished = true; - } - - /// - /// Runs the dispatcher's main loop. - /// - /// - /// A cancellation token used to exit the main loop. - /// - public void MainLoop(CancellationToken cancellationToken) + internal AvaloniaSynchronizationContext GetContextWithPriority(DispatcherPriority priority) { - if (_controlledImpl == null) - throw new PlatformNotSupportedException(); - cancellationToken.Register(() => RequestProcessing()); - _controlledImpl.RunLoop(cancellationToken); + DispatcherPriority.Validate(priority, nameof(priority)); + var index = priority - DispatcherPriority.MinValue; + return _priorityContexts[index] ??= new(priority); } } diff --git a/src/Avalonia.Base/Threading/DispatcherFrame.cs b/src/Avalonia.Base/Threading/DispatcherFrame.cs new file mode 100644 index 0000000000..1f8974dfa3 --- /dev/null +++ b/src/Avalonia.Base/Threading/DispatcherFrame.cs @@ -0,0 +1,125 @@ +using System; +using System.Threading; + +namespace Avalonia.Threading; + +/// +/// Representation of Dispatcher frame. +/// +public class DispatcherFrame +{ + private bool _exitWhenRequested; + private bool _continue; + private bool _isRunning; + private CancellationTokenSource? _cancellationTokenSource; + + /// + /// Constructs a new instance of the DispatcherFrame class. + /// + public DispatcherFrame() : this(true) + { + } + + public Dispatcher Dispatcher { get; } + + /// + /// Constructs a new instance of the DispatcherFrame class. + /// + /// + /// Indicates whether or not this frame will exit when all frames + /// are requested to exit. + ///

+ /// Dispatcher frames typically break down into two categories: + /// 1) Long running, general purpose frames, that exit only when + /// told to. These frames should exit when requested. + /// 2) Short running, very specific frames that exit themselves + /// when an important criteria is met. These frames may + /// consider not exiting when requested in favor of waiting + /// for their important criteria to be met. These frames + /// should have a timeout associated with them. + /// + public DispatcherFrame(bool exitWhenRequested) + { + Dispatcher = Dispatcher.UIThread; + Dispatcher.VerifyAccess(); + _exitWhenRequested = exitWhenRequested; + _continue = true; + } + + ///

+ /// Indicates that this dispatcher frame should exit. + /// + public bool Continue + { + get + { + // This method is free-threaded. + + // First check if this frame wants to continue. + bool shouldContinue = _continue; + if (shouldContinue) + { + // This frame wants to continue, so next check if it will + // respect the "exit requests" from the dispatcher. + if (_exitWhenRequested) + { + Dispatcher dispatcher = Dispatcher; + + // This frame is willing to respect the "exit requests" of + // the dispatcher, so check them. + if (dispatcher.ExitAllFramesRequested || dispatcher.HasShutdownStarted) + { + shouldContinue = false; + } + } + } + + return shouldContinue; + } + + set + { + // This method is free-threaded. + lock (Dispatcher.InstanceLock) + { + _continue = value; + if (!_continue) + _cancellationTokenSource?.Cancel(); + } + } + } + + internal void Run(IControlledDispatcherImpl impl) + { + // Since the actual platform run loop is controlled by a Cancellation token, we are restarting + // it if frame still needs to run + while (Continue) + RunCore(impl); + } + + private void RunCore(IControlledDispatcherImpl impl) + { + if (_isRunning) + throw new InvalidOperationException("This frame is already running"); + _isRunning = true; + try + { + _cancellationTokenSource = new CancellationTokenSource(); + // Wake up the dispatcher in case it has pending jobs + Dispatcher.RequestProcessing(); + impl.RunLoop(_cancellationTokenSource.Token); + } + finally + { + _isRunning = false; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource = null; + } + } + + internal void MaybeExitOnDispatcherRequest() + { + if (_exitWhenRequested) + _cancellationTokenSource?.Cancel(); + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Threading/DispatcherOperation.cs b/src/Avalonia.Base/Threading/DispatcherOperation.cs index 173ab81ef8..809c41ff02 100644 --- a/src/Avalonia.Base/Threading/DispatcherOperation.cs +++ b/src/Avalonia.Base/Threading/DispatcherOperation.cs @@ -101,21 +101,131 @@ public class DispatcherOperation } } - public void Abort() + public bool Abort() { lock (Dispatcher.InstanceLock) { - if (Status == DispatcherOperationStatus.Pending) - return; + if (Status != DispatcherOperationStatus.Pending) + return false; Dispatcher.Abort(this); + return true; } } - public void Wait() + /// + /// Waits for this operation to complete. + /// + /// + /// The status of the operation. To obtain the return value + /// of the invoked delegate, use the the Result property. + /// + public void Wait() => Wait(TimeSpan.FromMilliseconds(-1)); + + /// + /// Waits for this operation to complete. + /// + /// + /// The maximum amount of time to wait. + /// + public void Wait(TimeSpan timeout) { - if (Dispatcher.CheckAccess()) - throw new InvalidOperationException("Wait is only supported on background thread"); - GetTask().Wait(); + if ((Status == DispatcherOperationStatus.Pending || Status == DispatcherOperationStatus.Executing) && + timeout.TotalMilliseconds != 0) + { + if (Dispatcher.CheckAccess()) + { + if (Status == DispatcherOperationStatus.Executing) + { + // We are the dispatching thread, and the current operation state is + // executing, which means that the operation is in the middle of + // executing (on this thread) and is trying to wait for the execution + // to complete. Unfortunately, the thread will now deadlock, so + // 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 + { + cts.Cancel(); + }; + Completed += finishedHandler; + Aborted += finishedHandler; + try + { + while (Status == DispatcherOperationStatus.Pending) + { + if (Dispatcher.SupportsRunLoops) + { + if (Priority >= DispatcherPriority.MinimumForegroundPriority) + Dispatcher.RunJobs(Priority, cts.Token); + else + Dispatcher.PushFrame(new DispatcherOperationFrame(this, timeout)); + } + else + Dispatcher.RunJobs(DispatcherPriority.MinimumActiveValue, cts.Token); + } + } + finally + { + Completed -= finishedHandler; + Aborted -= finishedHandler; + } + } + } + GetTask().GetAwaiter().GetResult(); + } + + private class DispatcherOperationFrame : DispatcherFrame + { + // Note: we pass "exitWhenRequested=false" to the base + // DispatcherFrame construsctor because we do not want to exit + // this frame if the dispatcher is shutting down. This is + // because we may need to invoke operations during the shutdown process. + public DispatcherOperationFrame(DispatcherOperation op, TimeSpan timeout) : base(false) + { + _operation = op; + + // We will exit this frame once the operation is completed or aborted. + _operation.Aborted += OnCompletedOrAborted; + _operation.Completed += OnCompletedOrAborted; + + // We will exit the frame if the operation is not completed within + // the requested timeout. + if (timeout.TotalMilliseconds > 0) + { + _waitTimer = new Timer(_ => Exit(), + null, + timeout, + TimeSpan.FromMilliseconds(-1)); + } + + // Some other thread could have aborted the operation while we were + // setting up the handlers. We check the state again and mark the + // frame as "should not continue" if this happened. + if (_operation.Status != DispatcherOperationStatus.Pending) + { + Exit(); + } + } + + private void Exit() + { + Continue = false; + + if (_waitTimer != null) + { + _waitTimer.Dispose(); + } + + _operation.Aborted -= OnCompletedOrAborted; + _operation.Completed -= OnCompletedOrAborted; + } + + private void OnCompletedOrAborted(object? sender, EventArgs e) => Exit(); + + private DispatcherOperation _operation; + private Timer? _waitTimer; } public Task GetTask() => GetTaskCore(); @@ -148,7 +258,8 @@ public class DispatcherOperation try { - InvokeCore(); + using (AvaloniaSynchronizationContext.Ensure(Priority)) + InvokeCore(); } finally { diff --git a/src/Avalonia.Base/Threading/DispatcherPriority.cs b/src/Avalonia.Base/Threading/DispatcherPriority.cs index a71140d288..3017b45dc7 100644 --- a/src/Avalonia.Base/Threading/DispatcherPriority.cs +++ b/src/Avalonia.Base/Threading/DispatcherPriority.cs @@ -20,7 +20,9 @@ namespace Avalonia.Threading /// /// The lowest foreground dispatcher priority /// - internal static readonly DispatcherPriority Default = new(0); + public static readonly DispatcherPriority Default = new(0); + + internal static readonly DispatcherPriority MinimumForegroundPriority = Default; /// /// The job will be processed with the same priority as input. diff --git a/src/Windows/Avalonia.Win32/NonPumpingSyncContext.cs b/src/Avalonia.Base/Threading/NonPumpingSyncContext.cs similarity index 72% rename from src/Windows/Avalonia.Win32/NonPumpingSyncContext.cs rename to src/Avalonia.Base/Threading/NonPumpingSyncContext.cs index 2c4d2c9468..03fc0cc76c 100644 --- a/src/Windows/Avalonia.Win32/NonPumpingSyncContext.cs +++ b/src/Avalonia.Base/Threading/NonPumpingSyncContext.cs @@ -2,16 +2,17 @@ using System; using System.Runtime.ConstrainedExecution; using System.Threading; using Avalonia.Utilities; -using Avalonia.Win32.Interop; -namespace Avalonia.Win32 +namespace Avalonia.Threading { internal class NonPumpingSyncContext : SynchronizationContext, IDisposable { + private readonly NonPumpingLockHelper.IHelperImpl _impl; private readonly SynchronizationContext? _inner; - private NonPumpingSyncContext(SynchronizationContext? inner) + public NonPumpingSyncContext(NonPumpingLockHelper.IHelperImpl impl, SynchronizationContext? inner) { + _impl = impl; _inner = inner; SetWaitNotificationRequired(); SetSynchronizationContext(this); @@ -48,15 +49,12 @@ namespace Avalonia.Win32 #if !NET6_0_OR_GREATER [PrePrepareMethod] #endif - public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) - { - return UnmanagedMethods.WaitForMultipleObjectsEx(waitHandles.Length, waitHandles, waitAll, - millisecondsTimeout, false); - } + public override int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) => + _impl.Wait(waitHandles, waitAll, millisecondsTimeout); public void Dispose() => SetSynchronizationContext(_inner); - public static IDisposable? Use() + internal static IDisposable? Use(NonPumpingLockHelper.IHelperImpl impl) { var current = Current; if (current == null) @@ -67,12 +65,8 @@ namespace Avalonia.Win32 if (current is NonPumpingSyncContext) return null; - return new NonPumpingSyncContext(current); - } - - internal class HelperImpl : NonPumpingLockHelper.IHelperImpl - { - IDisposable? NonPumpingLockHelper.IHelperImpl.Use() => NonPumpingSyncContext.Use(); + return new NonPumpingSyncContext(impl, current); } + } } diff --git a/src/Avalonia.Base/Utilities/NonPumpingLockHelper.cs b/src/Avalonia.Base/Utilities/NonPumpingLockHelper.cs index 4cc1ba5190..55fd9a7957 100644 --- a/src/Avalonia.Base/Utilities/NonPumpingLockHelper.cs +++ b/src/Avalonia.Base/Utilities/NonPumpingLockHelper.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Threading; namespace Avalonia.Utilities { @@ -6,9 +7,15 @@ namespace Avalonia.Utilities { public interface IHelperImpl { - IDisposable? Use(); + int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout); } - public static IDisposable? Use() => AvaloniaLocator.Current.GetService()?.Use(); + public static IDisposable? Use() + { + var impl = AvaloniaLocator.Current.GetService(); + if (impl == null) + return null; + return NonPumpingSyncContext.Use(impl); + } } } diff --git a/src/Avalonia.Controls/ApplicationLifetimes/ClassicDesktopStyleApplicationLifetime.cs b/src/Avalonia.Controls/ApplicationLifetimes/ClassicDesktopStyleApplicationLifetime.cs index ada0b94124..9cc36e6a81 100644 --- a/src/Avalonia.Controls/ApplicationLifetimes/ClassicDesktopStyleApplicationLifetime.cs +++ b/src/Avalonia.Controls/ApplicationLifetimes/ClassicDesktopStyleApplicationLifetime.cs @@ -184,6 +184,7 @@ namespace Avalonia.Controls.ApplicationLifetimes _cts?.Cancel(); _cts = null; _isShuttingDown = false; + Dispatcher.UIThread.InvokeShutdown(); } return true; diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 325593508c..621bdd1017 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -527,5 +527,13 @@ namespace Avalonia.Controls } } } + + // Since we are resetting the dispatcher instance, the callback might never arrive + internal static void ResetLoadedQueueForUnitTests() + { + _loadedQueue.Clear(); + _loadedProcessingQueue.Clear(); + _isLoadedProcessing = false; + } } } diff --git a/src/Skia/Avalonia.Skia/TextShaperImpl.cs b/src/Skia/Avalonia.Skia/TextShaperImpl.cs index 7d7be46c72..aeb9025ade 100644 --- a/src/Skia/Avalonia.Skia/TextShaperImpl.cs +++ b/src/Skia/Avalonia.Skia/TextShaperImpl.cs @@ -26,8 +26,8 @@ namespace Avalonia.Skia using (var buffer = new Buffer()) { // HarfBuzz needs the surrounding characters to correctly shape the text - var containingText = GetContainingMemory(text, out var start, out var length); - buffer.AddUtf16(containingText.Span, start, length); + var containingText = GetContainingMemory(text, out var start, out var length).Span; + buffer.AddUtf16(containingText, start, length); MergeBreakPair(buffer); @@ -72,7 +72,7 @@ namespace Avalonia.Skia var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale); - if (i < textSpan.Length && textSpan[i] == '\t') + if (glyphCluster < containingText.Length && containingText[glyphCluster] == '\t') { glyphIndex = typeface.GetGlyph(' '); diff --git a/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs index 36897d3aff..b881129488 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Collections.Concurrent; using System.Globalization; using System.Runtime.InteropServices; using Avalonia.Media.TextFormatting; @@ -13,6 +14,8 @@ namespace Avalonia.Direct2D1.Media { internal class TextShaperImpl : ITextShaperImpl { + private static readonly ConcurrentDictionary s_cachedLanguage = new(); + public ShapedBuffer ShapeText(ReadOnlyMemory text, TextShaperOptions options) { var textSpan = text.Span; @@ -24,8 +27,8 @@ namespace Avalonia.Direct2D1.Media using (var buffer = new Buffer()) { // HarfBuzz needs the surrounding characters to correctly shape the text - var containingText = GetContainingMemory(text, out var start, out var length); - buffer.AddUtf16(containingText.Span, start, length); + var containingText = GetContainingMemory(text, out var start, out var length).Span; + buffer.AddUtf16(containingText, start, length); MergeBreakPair(buffer); @@ -33,7 +36,9 @@ namespace Avalonia.Direct2D1.Media buffer.Direction = (bidiLevel & 1) == 0 ? Direction.LeftToRight : Direction.RightToLeft; - buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture); + var usedCulture = culture ?? CultureInfo.CurrentCulture; + + buffer.Language = s_cachedLanguage.GetOrAdd(usedCulture.LCID, _ => new Language(usedCulture)); var font = ((GlyphTypefaceImpl)typeface).Font; @@ -68,7 +73,7 @@ namespace Avalonia.Direct2D1.Media var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale); - if (i < textSpan.Length && textSpan[i] == '\t') + if (glyphCluster < containingText.Length && containingText[glyphCluster] == '\t') { glyphIndex = typeface.GetGlyph(' '); diff --git a/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj b/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj index 92ebbd57b8..2c5f3e2ed1 100644 --- a/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj +++ b/src/Windows/Avalonia.Win32/Avalonia.Win32.csproj @@ -28,7 +28,7 @@ $(NoWarn);CA1416 - - + + diff --git a/src/Windows/Avalonia.Win32/NonPumpingWaitHelperImpl.cs b/src/Windows/Avalonia.Win32/NonPumpingWaitHelperImpl.cs new file mode 100644 index 0000000000..bda2d441ad --- /dev/null +++ b/src/Windows/Avalonia.Win32/NonPumpingWaitHelperImpl.cs @@ -0,0 +1,13 @@ +using System; +using Avalonia.Utilities; +using Avalonia.Win32.Interop; + +namespace Avalonia.Win32; + +internal class NonPumpingWaitHelperImpl : NonPumpingLockHelper.IHelperImpl +{ + public static NonPumpingWaitHelperImpl Instance { get; } = new(); + public int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) => + UnmanagedMethods.WaitForMultipleObjectsEx(waitHandles.Length, waitHandles, waitAll, + millisecondsTimeout, false); +} \ No newline at end of file diff --git a/src/Windows/Avalonia.Win32/Win32Platform.cs b/src/Windows/Avalonia.Win32/Win32Platform.cs index 857af3a783..7ce599c6bf 100644 --- a/src/Windows/Avalonia.Win32/Win32Platform.cs +++ b/src/Windows/Avalonia.Win32/Win32Platform.cs @@ -171,7 +171,7 @@ namespace Avalonia.Win32 } }) .Bind().ToConstant(s_instance) - .Bind().ToConstant(new NonPumpingSyncContext.HelperImpl()) + .Bind().ToConstant(NonPumpingWaitHelperImpl.Instance) .Bind().ToConstant(new WindowsMountedVolumeInfoProvider()) .Bind().ToConstant(s_instance); diff --git a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs index 2a40eb9f5b..39bdb942e1 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs @@ -579,7 +579,7 @@ namespace Avalonia.Win32 case WindowsMessage.WM_PAINT: { - using (NonPumpingSyncContext.Use()) + using (NonPumpingSyncContext.Use(NonPumpingWaitHelperImpl.Instance)) using (_rendererLock.Lock()) { if (BeginPaint(_hwnd, out PAINTSTRUCT ps) != IntPtr.Zero) @@ -602,7 +602,7 @@ namespace Avalonia.Win32 case WindowsMessage.WM_SIZE: { - using (NonPumpingSyncContext.Use()) + using (NonPumpingSyncContext.Use(NonPumpingWaitHelperImpl.Instance)) using (_rendererLock.Lock()) { // Do nothing here, just block until the pending frame render is completed on the render thread diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index 38175ad410..9ba3f3980d 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading; using Avalonia.Threading; +using Avalonia.Utilities; using Xunit; namespace Avalonia.Base.UnitTests; @@ -9,9 +12,14 @@ public class DispatcherTests { class SimpleDispatcherImpl : IDispatcherImpl, IDispatcherImplWithPendingInput { - public bool CurrentThreadIsLoopThread => true; - - public void Signal() => AskedForSignal = true; + private Thread _loopThread = Thread.CurrentThread; + private object _lock = new(); + public bool CurrentThreadIsLoopThread => Thread.CurrentThread == _loopThread; + public void Signal() + { + lock (_lock) + AskedForSignal = true; + } public event Action Signaled; public event Action Timer; @@ -27,9 +35,12 @@ public class DispatcherTests public void ExecuteSignal() { - if (!AskedForSignal) - return; - AskedForSignal = false; + lock (_lock) + { + if (!AskedForSignal) + return; + AskedForSignal = false; + } Signaled?.Invoke(); } @@ -45,6 +56,61 @@ public class DispatcherTests public bool HasPendingInput => TestInputPending == true; public bool? TestInputPending { get; set; } } + + class SimpleDispatcherWithBackgroundProcessingImpl : SimpleDispatcherImpl, IDispatcherImplWithExplicitBackgroundProcessing + { + public bool AskedForBackgroundProcessing { get; private set; } + public event Action ReadyForBackgroundProcessing; + public void RequestBackgroundProcessing() + { + if (!CurrentThreadIsLoopThread) + throw new InvalidOperationException(); + AskedForBackgroundProcessing = true; + } + + public void FireBackgroundProcessing() + { + if(!AskedForBackgroundProcessing) + return; + AskedForBackgroundProcessing = false; + 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) + { + _useTestTimeout = useTestTimeout; + _cancel = cancel; + } + + public void RunLoop(CancellationToken token) + { + RunLoopCount++; + var st = Stopwatch.StartNew(); + while (!token.IsCancellationRequested || _cancel?.IsCancellationRequested == true) + { + FireBackgroundProcessing(); + ExecuteSignal(); + if (_useTestTimeout) + Assert.True(st.ElapsedMilliseconds < 4000, "RunLoop exceeded test time quota"); + else + Thread.Sleep(10); + } + } + + + } [Fact] @@ -169,5 +235,227 @@ public class DispatcherTests impl.TestInputPending = false; } } + + [Theory, + InlineData(false, false), + InlineData(false, true), + InlineData(true, false), + InlineData(true, true)] + public void CanWaitForDispatcherOperationFromTheSameThread(bool controlled, bool foreground) + { + var impl = controlled ? new SimpleControlledDispatcherImpl() : new SimpleDispatcherImpl(); + var disp = new Dispatcher(impl); + bool finished = false; + + disp.InvokeAsync(() => finished = true, + foreground ? DispatcherPriority.Default : DispatcherPriority.Background).Wait(); + + Assert.True(finished); + if (controlled) + Assert.Equal(foreground ? 0 : 1, ((SimpleControlledDispatcherImpl)impl).RunLoopCount); + } + + + class DispatcherServices : IDisposable + { + private readonly IDisposable _scope; + + public DispatcherServices(IDispatcherImpl impl) + { + _scope = AvaloniaLocator.EnterScope(); + AvaloniaLocator.CurrentMutable.Bind().ToConstant(impl); + Dispatcher.ResetForUnitTests(); + SynchronizationContext.SetSynchronizationContext(null); + } + + public void Dispose() + { + Dispatcher.ResetForUnitTests(); + _scope.Dispose(); + SynchronizationContext.SetSynchronizationContext(null); + } + } + + [Fact] + public void ExitAllFramesShouldExitAllFramesAndBeAbleToContinue() + { + using (new DispatcherServices(new SimpleControlledDispatcherImpl())) + { + var actions = new List(); + var disp = Dispatcher.UIThread; + disp.Post(() => + { + actions.Add("Nested frame"); + Dispatcher.UIThread.MainLoop(CancellationToken.None); + actions.Add("Nested frame exited"); + }); + disp.Post(() => + { + actions.Add("ExitAllFrames"); + disp.ExitAllFrames(); + }); + + + disp.MainLoop(CancellationToken.None); + + Assert.Equal(new[] { "Nested frame", "ExitAllFrames", "Nested frame exited" }, actions); + actions.Clear(); + + var secondLoop = new CancellationTokenSource(); + disp.Post(() => + { + actions.Add("Callback after exit"); + secondLoop.Cancel(); + }); + disp.MainLoop(secondLoop.Token); + Assert.Equal(new[] { "Callback after exit" }, actions); + } + } + + + [Fact] + public void ShutdownShouldExitAllFramesAndNotAllowNewFrames() + { + using (new DispatcherServices(new SimpleControlledDispatcherImpl())) + { + var actions = new List(); + var disp = Dispatcher.UIThread; + disp.Post(() => + { + actions.Add("Nested frame"); + Dispatcher.UIThread.MainLoop(CancellationToken.None); + actions.Add("Nested frame exited"); + }); + disp.Post(() => + { + actions.Add("Shutdown"); + disp.BeginInvokeShutdown(DispatcherPriority.Normal); + }); + + disp.Post(() => + { + actions.Add("Nested frame after shutdown"); + // This should exit immediately and not run any jobs + 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"); + }); + disp.Post(() => + { + actions.Add("Stop critical frame"); + criticalFrameAfterShutdown.Continue = false; + }); + + disp.MainLoop(CancellationToken.None); + + Assert.Equal(new[] + { + "Nested frame", + "Shutdown", + // Normal nested frames are supposed to exit immediately + "Nested frame after shutdown", "Nested frame after shutdown exited", + // if frame is configured to not answer dispatcher requests, it should be allowed to run + "Critical frame after shutdown", "Stop critical frame", "Critical frame after shutdown exited", + // After 3-rd level frames have exited, the normal nested frame exits too + "Nested frame exited" + }, actions); + actions.Clear(); + + disp.Post(()=>actions.Add("Frame after shutdown finished")); + Assert.Throws(() => disp.MainLoop(CancellationToken.None)); + Assert.Empty(actions); + } + } + + class WaitHelper : SynchronizationContext, NonPumpingLockHelper.IHelperImpl + { + public int WaitCount; + public int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout) + { + WaitCount++; + return base.Wait(waitHandles, waitAll, millisecondsTimeout); + } + } + [Fact] + public void DisableProcessingShouldStopProcessing() + { + using (new DispatcherServices(new SimpleControlledDispatcherImpl())) + { + var helper = new WaitHelper(); + AvaloniaLocator.CurrentMutable.Bind().ToConstant(helper); + using (Dispatcher.UIThread.DisableProcessing()) + { + Assert.True(SynchronizationContext.Current is NonPumpingSyncContext); + Assert.Throws(() => Dispatcher.UIThread.MainLoop(CancellationToken.None)); + Assert.Throws(() => Dispatcher.UIThread.RunJobs()); + } + + var avaloniaContext = new AvaloniaSynchronizationContext(true); + SynchronizationContext.SetSynchronizationContext(avaloniaContext); + + var waitHandle = new ManualResetEvent(true); + + helper.WaitCount = 0; + waitHandle.WaitOne(100); + Assert.Equal(0, helper.WaitCount); + using (Dispatcher.UIThread.DisableProcessing()) + { + Assert.Equal(avaloniaContext, SynchronizationContext.Current); + waitHandle.WaitOne(100); + Assert.Equal(1, helper.WaitCount); + } + } + } + + [Fact] + public void DispatcherOperationsHaveContextWithProperPriority() + { + using (new DispatcherServices(new SimpleControlledDispatcherImpl())) + { + SynchronizationContext.SetSynchronizationContext(null); + var disp = Dispatcher.UIThread; + var priorities = new List(); + + void DumpCurrentPriority() => + priorities.Add(((AvaloniaSynchronizationContext)SynchronizationContext.Current!).Priority); + + + disp.Post(DumpCurrentPriority, DispatcherPriority.Normal); + disp.Post(DumpCurrentPriority, DispatcherPriority.Loaded); + disp.Post(DumpCurrentPriority, DispatcherPriority.Input); + disp.Post(() => + { + DumpCurrentPriority(); + disp.ExitAllFrames(); + }, DispatcherPriority.Background); + disp.MainLoop(CancellationToken.None); + + disp.Invoke(DumpCurrentPriority, DispatcherPriority.Send); + disp.Invoke(() => + { + DumpCurrentPriority(); + return 1; + }, DispatcherPriority.Send); + + Assert.Equal( + new[] + { + DispatcherPriority.Normal, DispatcherPriority.Loaded, DispatcherPriority.Input, DispatcherPriority.Background, + DispatcherPriority.Send, DispatcherPriority.Send, + }, + priorities); + + + } + } + } \ No newline at end of file diff --git a/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs b/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs index 90a0db691c..58811cd081 100644 --- a/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DesktopStyleApplicationLifetimeTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Threading; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls.Platform; using Avalonia.Platform; using Avalonia.Rendering; using Avalonia.Threading; @@ -13,17 +15,22 @@ namespace Avalonia.Controls.UnitTests public class DesktopStyleApplicationLifetimeTests { - IDispatcherImpl CreateDispatcherWithInstantMainLoop() => Mock.Of(x => x.CurrentThreadIsLoopThread == true); - - + IDispatcherImpl CreateDispatcherWithInstantMainLoop() + { + var mock = new Mock(); + mock.Setup(x => x.RunLoop(It.IsAny())) + .Callback(() => Dispatcher.UIThread.ExitAllFrames()); + mock.Setup(x => x.CurrentThreadIsLoopThread).Returns(true); + return mock.Object; + } + [Fact] public void Should_Set_ExitCode_After_Shutdown() { - using (UnitTestApplication.Start(new TestServices(dispatcherImpl: CreateDispatcherWithInstantMainLoop()))) + using (UnitTestApplication.Start(new TestServices(dispatcherImpl: new ManagedDispatcherImpl(null)))) using(var lifetime = new ClassicDesktopStyleApplicationLifetime()) { - lifetime.Shutdown(1337); - + Dispatcher.UIThread.Post(() => lifetime.Shutdown(1337)); var exitCode = lifetime.Start(Array.Empty()); Assert.Equal(1337, exitCode); @@ -219,11 +226,14 @@ namespace Avalonia.Controls.UnitTests [Fact] public void Should_Allow_Canceling_Shutdown_Via_ShutdownRequested_Event() { - using (UnitTestApplication.Start(TestServices.StyledWindow.With(dispatcherImpl: CreateDispatcherWithInstantMainLoop()))) + using (UnitTestApplication.Start(TestServices.StyledWindow.With(dispatcherImpl: new ManagedDispatcherImpl(null)))) using (var lifetime = new ClassicDesktopStyleApplicationLifetime()) { var lifetimeEvents = new Mock(); AvaloniaLocator.CurrentMutable.Bind().ToConstant(lifetimeEvents.Object); + + // Force exit immediately + Dispatcher.UIThread.Post(Dispatcher.UIThread.ExitAllFrames); lifetime.Start(Array.Empty()); var window = new Window(); @@ -356,7 +366,7 @@ namespace Avalonia.Controls.UnitTests [Fact] public void Shutdown_NotCancellable_By_Preventing_Window_Close() { - using (UnitTestApplication.Start(TestServices.StyledWindow)) + using (UnitTestApplication.Start(TestServices.StyledWindow.With(dispatcherImpl: CreateDispatcherWithInstantMainLoop()))) using(var lifetime = new ClassicDesktopStyleApplicationLifetime()) { var hasExit = false; diff --git a/tests/Avalonia.Controls.UnitTests/LoadedTests.cs b/tests/Avalonia.Controls.UnitTests/LoadedTests.cs index aaf0dce30e..555ba02409 100644 --- a/tests/Avalonia.Controls.UnitTests/LoadedTests.cs +++ b/tests/Avalonia.Controls.UnitTests/LoadedTests.cs @@ -11,6 +11,8 @@ public class LoadedTests [Fact] public void Window_Loads_And_Unloads() { + // Some other tests are populating the queue and are not resetting the dispatcher, so we need to purge it + Control.ResetLoadedQueueForUnitTests(); using (UnitTestApplication.Start(TestServices.StyledWindow)) { int loadedCount = 0, unloadedCount = 0; @@ -40,6 +42,8 @@ public class LoadedTests [Fact] public void Control_Loads_And_Unloads() { + // Some other tests are populating the queue and are not resetting the dispatcher, so we need to purge it + Control.ResetLoadedQueueForUnitTests(); using (UnitTestApplication.Start(TestServices.StyledWindow)) { int loadedCount = 0, unloadedCount = 0; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index a24da38fdd..c84fcaaa9a 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -1051,7 +1051,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting [InlineData("שנב🧐שנב", 2, 4, FlowDirection.LeftToRight, "11.268,38.208")] [InlineData("שנב🧐שנב", 2, 4, FlowDirection.RightToLeft, "11.268,38.208")] [Theory] - public void Should_HitTextTextRangeBetweenRuns(string text, int start, int length, + public void Should_HitTestTextRangeBetweenRuns(string text, int start, int length, FlowDirection flowDirection, string expected) { using (Start()) @@ -1087,6 +1087,30 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Fact] + public void Should_HitTestTextRangeWithLineBreaks() + { + using (Start()) + { + var beforeLinebreak = "Line before linebreak"; + var afterLinebreak = "Line after linebreak"; + var text = beforeLinebreak + Environment.NewLine + "" + Environment.NewLine + afterLinebreak; + + var textLayout = new TextLayout(text, Typeface.Default, 12, Brushes.Black); + + var end = text.Length - afterLinebreak.Length + 1; + + var rects = textLayout.HitTestTextRange(0, end).ToArray(); + + Assert.Equal(3, rects.Length); + + var endX = textLayout.TextLines[2].GetDistanceFromCharacterHit(new CharacterHit(end)); + + //First character should be covered + Assert.Equal(7.201171875, endX, 2); + } + } + private static IDisposable Start() { diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 0cfa78d007..0ddcb2452d 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -622,6 +622,30 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Fact] + public void Should_Get_TextBounds_For_LineBreak() + { + using (Start()) + { + var defaultProperties = new GenericTextRunProperties(Typeface.Default); + var textSource = new SingleBufferTextSource(Environment.NewLine, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + var textBounds = textLine.GetTextBounds(0, Environment.NewLine.Length); + + Assert.Equal(1, textBounds.Count); + + Assert.Equal(1, textBounds[0].TextRunBounds.Count); + + Assert.Equal(Environment.NewLine.Length, textBounds[0].TextRunBounds[0].Length); + } + } + [Fact] public void Should_GetTextRange() { diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs index 92778793c2..b748f8fc58 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs @@ -31,11 +31,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { using (Start()) { - var text = "\t"; + var text = "012345\t"; var options = new TextShaperOptions(Typeface.Default.GlyphTypeface, 12, 0, CultureInfo.CurrentCulture, 100); - var shapedBuffer = TextShaper.Current.ShapeText(text, options); + var shapedBuffer = TextShaper.Current.ShapeText(text.AsMemory().Slice(6), options); - Assert.Equal(shapedBuffer.Length, text.Length); + Assert.Equal(1, shapedBuffer.Length); Assert.Equal(100, shapedBuffer[0].GlyphAdvance); } } diff --git a/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs index 375f87259d..98f188f15a 100644 --- a/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs +++ b/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Collections.Concurrent; using System.Globalization; using System.Runtime.InteropServices; using Avalonia.Media.TextFormatting; @@ -13,6 +14,7 @@ namespace Avalonia.UnitTests { internal class HarfBuzzTextShaperImpl : ITextShaperImpl { + private static readonly ConcurrentDictionary s_cachedLanguage = new(); public ShapedBuffer ShapeText(ReadOnlyMemory text, TextShaperOptions options) { var textSpan = text.Span; @@ -24,8 +26,8 @@ namespace Avalonia.UnitTests using (var buffer = new Buffer()) { // HarfBuzz needs the surrounding characters to correctly shape the text - var containingText = GetContainingMemory(text, out var start, out var length); - buffer.AddUtf16(containingText.Span, start, length); + var containingText = GetContainingMemory(text, out var start, out var length).Span; + buffer.AddUtf16(containingText, start, length); MergeBreakPair(buffer); @@ -33,7 +35,9 @@ namespace Avalonia.UnitTests buffer.Direction = (bidiLevel & 1) == 0 ? Direction.LeftToRight : Direction.RightToLeft; - buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture); + var usedCulture = culture ?? CultureInfo.CurrentCulture; + + buffer.Language = s_cachedLanguage.GetOrAdd(usedCulture.LCID, _ => new Language(usedCulture)); var font = ((HarfBuzzGlyphTypefaceImpl)typeface).Font; @@ -68,7 +72,7 @@ namespace Avalonia.UnitTests var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale); - if (textSpan[i] == '\t') + if (glyphCluster < containingText.Length && containingText[glyphCluster] == '\t') { glyphIndex = typeface.GetGlyph(' '); diff --git a/tests/Avalonia.UnitTests/UnitTestApplication.cs b/tests/Avalonia.UnitTests/UnitTestApplication.cs index 8d9f08caa1..870e6391d0 100644 --- a/tests/Avalonia.UnitTests/UnitTestApplication.cs +++ b/tests/Avalonia.UnitTests/UnitTestApplication.cs @@ -8,6 +8,7 @@ using Avalonia.Rendering; using Avalonia.Threading; using System.Reactive.Disposables; using System.Reactive.Concurrency; +using System.Threading; using Avalonia.Input.Platform; using Avalonia.Animation; using Avalonia.Media; @@ -42,7 +43,8 @@ namespace Avalonia.UnitTests public static IDisposable Start(TestServices services = null) { var scope = AvaloniaLocator.EnterScope(); - var app = new UnitTestApplication(services); + var oldContext = SynchronizationContext.Current; + _ = new UnitTestApplication(services); Dispatcher.ResetForUnitTests(); return Disposable.Create(() => { @@ -53,6 +55,7 @@ namespace Avalonia.UnitTests scope.Dispose(); Dispatcher.ResetForUnitTests(); + SynchronizationContext.SetSynchronizationContext(oldContext); }); }