diff --git a/samples/ControlCatalog/Pages/ColorPickerPage.xaml b/samples/ControlCatalog/Pages/ColorPickerPage.xaml index 649256ba83..b759720cf2 100644 --- a/samples/ControlCatalog/Pages/ColorPickerPage.xaml +++ b/samples/ControlCatalog/Pages/ColorPickerPage.xaml @@ -28,6 +28,41 @@ + + + 24 + 18 + 12 + 9 + + + + + + + + + + + + + + + + + + + + + { + if (_client == null) + return null; + _inputConnection = new AvaloniaInputConnection(topLevel, this); outAttrs.InputType = options.ContentType switch diff --git a/src/Avalonia.Base/Layout/Layoutable.cs b/src/Avalonia.Base/Layout/Layoutable.cs index ea84dc84bd..ed88b73149 100644 --- a/src/Avalonia.Base/Layout/Layoutable.cs +++ b/src/Avalonia.Base/Layout/Layoutable.cs @@ -125,7 +125,7 @@ namespace Avalonia.Layout AvaloniaProperty.Register(nameof(VerticalAlignment)); /// - /// Defines the property. + /// Defines the property. /// public static readonly StyledProperty UseLayoutRoundingProperty = AvaloniaProperty.Register(nameof(UseLayoutRounding), defaultValue: true, inherits: true); diff --git a/src/Avalonia.Base/Media/Color.cs b/src/Avalonia.Base/Media/Color.cs index 74e70b2a14..f06f272e51 100644 --- a/src/Avalonia.Base/Media/Color.cs +++ b/src/Avalonia.Base/Media/Color.cs @@ -309,7 +309,7 @@ namespace Avalonia.Media if (input.Length == 3 || input.Length == 4) { var extendedLength = 2 * input.Length; - + #if !BUILDTASK Span extended = stackalloc char[extendedLength]; #else diff --git a/src/Avalonia.Base/Media/HsvColor.cs b/src/Avalonia.Base/Media/HsvColor.cs index f97457c54d..df68252065 100644 --- a/src/Avalonia.Base/Media/HsvColor.cs +++ b/src/Avalonia.Base/Media/HsvColor.cs @@ -131,7 +131,7 @@ namespace Avalonia.Media /// /// /// - /// 0 is a shade of gray (no color). + /// 0 is fully white (or a shade of gray) and shows no color. /// 1 is the full color. /// /// diff --git a/src/Avalonia.Base/Media/Imaging/PixelFormatReaders.cs b/src/Avalonia.Base/Media/Imaging/PixelFormatReaders.cs index fc7c174ed6..c90c4cb5ac 100644 --- a/src/Avalonia.Base/Media/Imaging/PixelFormatReaders.cs +++ b/src/Avalonia.Base/Media/Imaging/PixelFormatReaders.cs @@ -228,6 +228,44 @@ static unsafe class PixelFormatReader public void Reset(IntPtr address) => _address = (Rgba64*)address; } + + public unsafe struct Rgb24PixelFormatReader : IPixelFormatReader + { + private byte* _address; + public Rgba8888Pixel ReadNext() + { + var addr = _address; + _address += 3; + return new Rgba8888Pixel + { + R = addr[0], + G = addr[1], + B = addr[2], + A = 255, + }; + } + + public void Reset(IntPtr address) => _address = (byte*)address; + } + + public unsafe struct Bgr24PixelFormatReader : IPixelFormatReader + { + private byte* _address; + public Rgba8888Pixel ReadNext() + { + var addr = _address; + _address += 3; + return new Rgba8888Pixel + { + R = addr[2], + G = addr[1], + B = addr[0], + A = 255, + }; + } + + public void Reset(IntPtr address) => _address = (byte*)address; + } public static void Transcode(IntPtr dst, IntPtr src, PixelSize size, int strideSrc, int strideDst, PixelFormat format) @@ -242,6 +280,10 @@ static unsafe class PixelFormatReader Transcode(dst, src, size, strideSrc, strideDst); else if (format == PixelFormats.Gray16) Transcode(dst, src, size, strideSrc, strideDst); + else if (format == PixelFormats.Rgb24) + Transcode(dst, src, size, strideSrc, strideDst); + else if (format == PixelFormats.Bgr24) + Transcode(dst, src, size, strideSrc, strideDst); else if (format == PixelFormats.Gray32Float) Transcode(dst, src, size, strideSrc, strideDst); else if (format == PixelFormats.Rgba64) @@ -258,7 +300,9 @@ static unsafe class PixelFormatReader || format == PixelFormats.Gray8 || format == PixelFormats.Gray16 || format == PixelFormats.Gray32Float - || format == PixelFormats.Rgba64; + || format == PixelFormats.Rgba64 + || format == PixelFormats.Bgr24 + || format == PixelFormats.Rgb24; } public static void Transcode(IntPtr dst, IntPtr src, PixelSize size, int strideSrc, int strideDst) where TReader : struct, IPixelFormatReader 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/Platform/PixelFormat.cs b/src/Avalonia.Base/Platform/PixelFormat.cs index 99fe17055d..95f49bdb25 100644 --- a/src/Avalonia.Base/Platform/PixelFormat.cs +++ b/src/Avalonia.Base/Platform/PixelFormat.cs @@ -13,7 +13,9 @@ namespace Avalonia.Platform Gray8, Gray16, Gray32Float, - Rgba64 + Rgba64, + Rgb24, + Bgr24 } public record struct PixelFormat @@ -35,6 +37,8 @@ namespace Avalonia.Platform else if (FormatEnum == PixelFormatEnum.Rgb565 || FormatEnum == PixelFormatEnum.Gray16) return 16; + else if (FormatEnum is PixelFormatEnum.Bgr24 or PixelFormatEnum.Rgb24) + return 24; else if (FormatEnum == PixelFormatEnum.Rgba64) return 64; @@ -70,5 +74,7 @@ namespace Avalonia.Platform public static PixelFormat Gray8 { get; } = new PixelFormat(PixelFormatEnum.Gray8); public static PixelFormat Gray16 { get; } = new PixelFormat(PixelFormatEnum.Gray16); public static PixelFormat Gray32Float { get; } = new PixelFormat(PixelFormatEnum.Gray32Float); + public static PixelFormat Rgb24 { get; } = new PixelFormat(PixelFormatEnum.Rgb24); + public static PixelFormat Bgr24 { get; } = new PixelFormat(PixelFormatEnum.Bgr24); } } diff --git a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs index 032a3046d7..8916983a5c 100644 --- a/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs +++ b/src/Avalonia.Base/Rendering/Composition/Transport/BatchStreamArrayPool.cs @@ -72,6 +72,11 @@ internal abstract class BatchStreamPoolBase : IDisposable protected abstract T CreateItem(); + protected virtual void ClearItem(T item) + { + + } + protected virtual void DestroyItem(T item) { @@ -94,6 +99,7 @@ internal abstract class BatchStreamPoolBase : IDisposable public void Return(T item) { + ClearItem(item); lock (_pool) { _usage--; @@ -138,7 +144,7 @@ internal sealed class BatchStreamObjectPool : BatchStreamPoolBase where return new T[ArraySize]; } - protected override void DestroyItem(T[] item) + protected override void ClearItem(T[] item) { Array.Clear(item, 0, item.Length); } diff --git a/src/Avalonia.Base/Styling/ITemplate.cs b/src/Avalonia.Base/Styling/ITemplate.cs index 8a130cb3b4..d183d3ff74 100644 --- a/src/Avalonia.Base/Styling/ITemplate.cs +++ b/src/Avalonia.Base/Styling/ITemplate.cs @@ -2,6 +2,6 @@ { public interface ITemplate { - object Build(); + object? Build(); } } 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.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index df0c5b100f..b4c5b2a1d2 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -50,7 +50,7 @@ namespace Avalonia AvaloniaProperty.Register(nameof(Clip)); /// - /// Defines the property. + /// Defines the property. /// public static readonly StyledProperty IsVisibleProperty = AvaloniaProperty.Register(nameof(IsVisible), true); diff --git a/src/Avalonia.Controls.ColorPicker/ColorPicker/ColorPicker.cs b/src/Avalonia.Controls.ColorPicker/ColorPicker/ColorPicker.cs index 01cb745ba7..92d8535272 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorPicker/ColorPicker.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorPicker/ColorPicker.cs @@ -1,6 +1,4 @@ -using Avalonia.Controls.Primitives; - -namespace Avalonia.Controls +namespace Avalonia.Controls { /// /// Presents a color for user editing using a spectrum, palette and component sliders within a drop down. diff --git a/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs b/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs index 6f49430505..58702ecb61 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorPreviewer/ColorPreviewer.cs @@ -124,7 +124,7 @@ namespace Avalonia.Controls.Primitives if (accentStep != 0) { // ColorChanged will be invoked in OnPropertyChanged if the value is different - HsvColor = AccentColorConverter.GetAccent(hsvColor, accentStep); + SetCurrentValue(HsvColorProperty, AccentColorConverter.GetAccent(hsvColor, accentStep)); } } } diff --git a/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs b/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs index dd5e7d5b01..ce47a797ec 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorSlider/ColorSlider.cs @@ -96,8 +96,22 @@ namespace Avalonia.Controls.Primitives // independent pixels of controls. var scale = LayoutHelper.GetLayoutScale(this); - var pixelWidth = Convert.ToInt32(Bounds.Width * scale); - var pixelHeight = Convert.ToInt32(Bounds.Height * scale); + int pixelWidth; + int pixelHeight; + + if (base._track != null) + { + pixelWidth = Convert.ToInt32(base._track.Bounds.Width * scale); + pixelHeight = Convert.ToInt32(base._track.Bounds.Height * scale); + } + else + { + // As a fallback, attempt to calculate using the overall control size + // This shouldn't happen as a track is a required template part of a slider + // However, if it does, the spectrum will still be shown + pixelWidth = Convert.ToInt32(Bounds.Width * scale); + pixelHeight = Convert.ToInt32(Bounds.Height * scale); + } if (pixelWidth != 0 && pixelHeight != 0) { @@ -373,7 +387,7 @@ namespace Avalonia.Controls.Primitives ignorePropertyChanged = true; // Always keep the two color properties in sync - HsvColor = Color.ToHsv(); + SetCurrentValue(HsvColorProperty, Color.ToHsv()); SetColorToSliderValues(); UpdateBackground(); @@ -403,7 +417,7 @@ namespace Avalonia.Controls.Primitives ignorePropertyChanged = true; // Always keep the two color properties in sync - Color = HsvColor.ToRgb(); + SetCurrentValue(ColorProperty, HsvColor.ToRgb()); SetColorToSliderValues(); UpdateBackground(); @@ -440,13 +454,13 @@ namespace Avalonia.Controls.Primitives if (ColorModel == ColorModel.Hsva) { - HsvColor = hsvColor; - Color = hsvColor.ToRgb(); + SetCurrentValue(HsvColorProperty, hsvColor); + SetCurrentValue(ColorProperty, hsvColor.ToRgb()); } else { - Color = color; - HsvColor = color.ToHsv(); + SetCurrentValue(ColorProperty, color); + SetCurrentValue(HsvColorProperty, color.ToHsv()); } UpdatePseudoClasses(); diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs index 5c7de2459b..2245eb8022 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.Properties.cs @@ -96,10 +96,10 @@ namespace Avalonia.Controls.Primitives /// /// Defines the property. /// - public static readonly StyledProperty ThirdComponentProperty = - AvaloniaProperty.Register( + public static readonly DirectProperty ThirdComponentProperty = + AvaloniaProperty.RegisterDirect( nameof(ThirdComponent), - ColorComponent.Component3); // Value + o => o.ThirdComponent); /// /// Gets or sets the currently selected color in the RGB color model. @@ -239,8 +239,8 @@ namespace Avalonia.Controls.Primitives /// public ColorComponent ThirdComponent { - get => GetValue(ThirdComponentProperty); - protected set => SetValue(ThirdComponentProperty, value); + get => _thirdComponent; + private set => SetAndRaise(ThirdComponentProperty, ref _thirdComponent, value); } } } diff --git a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs index 6f4c0003a8..9198a2f237 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorSpectrum/ColorSpectrum.cs @@ -13,9 +13,9 @@ using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.Imaging; +using Avalonia.Reactive; using Avalonia.Threading; using Avalonia.Utilities; -using Avalonia.Reactive; namespace Avalonia.Controls.Primitives { @@ -48,6 +48,7 @@ namespace Avalonia.Controls.Primitives private bool _isPointerPressed = false; private bool _shouldShowLargeSelection = false; private List _hsvValues = new List(); + private ColorComponent _thirdComponent = ColorComponent.Component3; // HsvComponent.Value private IDisposable? _layoutRootDisposable; private IDisposable? _selectionEllipsePanelDisposable; @@ -403,7 +404,7 @@ namespace Avalonia.Controls.Primitives _updatingHsvColor = true; Hsv newHsv = (new Rgb(color)).ToHsv(); - HsvColor = newHsv.ToHsvColor(color.A / 255.0); + SetCurrentValue(HsvColorProperty, newHsv.ToHsvColor(color.A / 255.0)); _updatingHsvColor = false; UpdateEllipse(); @@ -534,7 +535,7 @@ namespace Avalonia.Controls.Primitives _updatingColor = true; Rgb newRgb = (new Hsv(hsvColor)).ToRgb(); - Color = newRgb.ToColor(hsvColor.A); + SetCurrentValue(ColorProperty, newRgb.ToColor(hsvColor.A)); _updatingColor = false; @@ -608,8 +609,8 @@ namespace Avalonia.Controls.Primitives Rgb newRgb = newHsv.ToRgb(); double alpha = HsvColor.A; - Color = newRgb.ToColor(alpha); - HsvColor = newHsv.ToHsvColor(alpha); + SetCurrentValue(ColorProperty, newRgb.ToColor(alpha)); + SetCurrentValue(HsvColorProperty, newHsv.ToHsvColor(alpha)); UpdateEllipse(); UpdatePseudoClasses(); diff --git a/src/Avalonia.Controls.ColorPicker/ColorView/AlphaComponentPosition.cs b/src/Avalonia.Controls.ColorPicker/ColorView/AlphaComponentPosition.cs new file mode 100644 index 0000000000..4f3ae46a24 --- /dev/null +++ b/src/Avalonia.Controls.ColorPicker/ColorView/AlphaComponentPosition.cs @@ -0,0 +1,26 @@ +namespace Avalonia.Controls +{ + /// + /// Defines the position of a color's alpha component relative to all other components. + /// + public enum AlphaComponentPosition + { + /// + /// The alpha component occurs before all other components. + /// + /// + /// For example, this may indicate the #AARRGGBB or ARGB format which + /// is the default format for XAML itself and the Color struct. + /// + Leading, + + /// + /// The alpha component occurs after all other components. + /// + /// + /// For example, this may indicate the #RRGGBBAA or RGBA format which + /// is the default format for CSS. + /// + Trailing, + } +} diff --git a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.Properties.cs b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.Properties.cs index b76059037b..e334a1d323 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.Properties.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.Properties.cs @@ -42,6 +42,14 @@ namespace Avalonia.Controls nameof(ColorSpectrumShape), ColorSpectrumShape.Box); + /// + /// Defines the property. + /// + public static readonly StyledProperty HexInputAlphaPositionProperty = + AvaloniaProperty.Register( + nameof(HexInputAlphaPosition), + AlphaComponentPosition.Trailing); // Match CSS (and default slider order) instead of XAML/WinUI + /// /// Defines the property. /// @@ -260,6 +268,16 @@ namespace Avalonia.Controls set => SetValue(ColorSpectrumShapeProperty, value); } + /// + /// Gets or sets the position of the alpha component in the hexadecimal input box relative to + /// all other color components. + /// + public AlphaComponentPosition HexInputAlphaPosition + { + get => GetValue(HexInputAlphaPositionProperty); + set => SetValue(HexInputAlphaPositionProperty, value); + } + /// public HsvColor HsvColor { diff --git a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs index 977b1f5c84..274e7f5851 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs @@ -1,14 +1,10 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Globalization; using Avalonia.Controls.Converters; using Avalonia.Controls.Metadata; -using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Media; using Avalonia.Threading; -using Avalonia.VisualTree; namespace Avalonia.Controls { @@ -28,8 +24,7 @@ namespace Avalonia.Controls private TextBox? _hexTextBox; private TabControl? _tabControl; - private ColorToHexConverter colorToHexConverter = new ColorToHexConverter(); - protected bool ignorePropertyChanged = false; + protected bool _ignorePropertyChanged = false; /// /// Initializes a new instance of the class. @@ -46,11 +41,11 @@ namespace Avalonia.Controls { if (_hexTextBox != null) { - var convertedColor = colorToHexConverter.ConvertBack(_hexTextBox.Text, typeof(Color), null, CultureInfo.CurrentCulture); + var convertedColor = ColorToHexConverter.ParseHexString(_hexTextBox.Text ?? string.Empty, HexInputAlphaPosition); if (convertedColor is Color color) { - Color = color; + SetCurrentValue(ColorProperty, color); } // Re-apply the hex value @@ -66,7 +61,7 @@ namespace Avalonia.Controls { if (_hexTextBox != null) { - _hexTextBox.Text = colorToHexConverter.Convert(Color, typeof(string), null, CultureInfo.CurrentCulture) as string; + _hexTextBox.Text = ColorToHexConverter.ToHexString(Color, HexInputAlphaPosition); } } @@ -167,7 +162,7 @@ namespace Avalonia.Controls // The work-around for this is done here where SelectedIndex is forcefully // synchronized with whatever the TabControl property value is. This is // possible since selection validation is already done by this method. - SelectedIndex = _tabControl.SelectedIndex; + SetCurrentValue(SelectedIndexProperty, _tabControl.SelectedIndex); } return; @@ -200,7 +195,7 @@ namespace Avalonia.Controls /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { - if (ignorePropertyChanged) + if (_ignorePropertyChanged) { base.OnPropertyChanged(change); return; @@ -209,29 +204,29 @@ namespace Avalonia.Controls // Always keep the two color properties in sync if (change.Property == ColorProperty) { - ignorePropertyChanged = true; + _ignorePropertyChanged = true; - HsvColor = Color.ToHsv(); + SetCurrentValue(HsvColorProperty, Color.ToHsv()); SetColorToHexTextBox(); OnColorChanged(new ColorChangedEventArgs( change.GetOldValue(), change.GetNewValue())); - ignorePropertyChanged = false; + _ignorePropertyChanged = false; } else if (change.Property == HsvColorProperty) { - ignorePropertyChanged = true; + _ignorePropertyChanged = true; - Color = HsvColor.ToRgb(); + SetCurrentValue(ColorProperty, HsvColor.ToRgb()); SetColorToHexTextBox(); OnColorChanged(new ColorChangedEventArgs( change.GetOldValue().ToRgb(), change.GetNewValue().ToRgb())); - ignorePropertyChanged = false; + _ignorePropertyChanged = false; } else if (change.Property == PaletteProperty) { @@ -241,7 +236,7 @@ namespace Avalonia.Controls // bound properties controlling the palette grid if (palette != null) { - PaletteColumnCount = palette.ColorCount; + SetCurrentValue(PaletteColumnCountProperty, palette.ColorCount); List newPaletteColors = new List(); for (int shadeIndex = 0; shadeIndex < palette.ShadeCount; shadeIndex++) @@ -252,14 +247,14 @@ namespace Avalonia.Controls } } - PaletteColors = newPaletteColors; + SetCurrentValue(PaletteColorsProperty, newPaletteColors); } } else if (change.Property == IsAlphaEnabledProperty) { // Manually coerce the HsvColor value // (Color will be coerced automatically if HsvColor changes) - HsvColor = OnCoerceHsvColor(HsvColor); + SetCurrentValue(HsvColorProperty, OnCoerceHsvColor(HsvColor)); } else if (change.Property == IsColorComponentsVisibleProperty || change.Property == IsColorPaletteVisibleProperty || diff --git a/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs b/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs index 8d5f2332be..8798f874f4 100644 --- a/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs +++ b/src/Avalonia.Controls.ColorPicker/Converters/ColorToHexConverter.cs @@ -2,6 +2,7 @@ using System.Globalization; using Avalonia.Data.Converters; using Avalonia.Media; +using Avalonia.Utilities; namespace Avalonia.Controls.Converters { @@ -10,6 +11,11 @@ namespace Avalonia.Controls.Converters /// public class ColorToHexConverter : IValueConverter { + /// + /// Gets or sets the position of a color's alpha component relative to all other components. + /// + public AlphaComponentPosition AlphaPosition { get; set; } = AlphaComponentPosition.Leading; + /// public object? Convert( object? value, @@ -42,16 +48,7 @@ namespace Avalonia.Controls.Converters return AvaloniaProperty.UnsetValue; } - string hexColor = color.ToUint32().ToString("x8", CultureInfo.InvariantCulture).ToUpperInvariant(); - - if (includeSymbol == false) - { - // TODO: When .net standard 2.0 is dropped, replace the below line - //hexColor = hexColor.Replace("#", string.Empty, StringComparison.Ordinal); - hexColor = hexColor.Replace("#", string.Empty); - } - - return hexColor; + return ToHexString(color, AlphaPosition, includeSymbol); } /// @@ -62,21 +59,159 @@ namespace Avalonia.Controls.Converters CultureInfo culture) { string hexValue = value?.ToString() ?? string.Empty; + return ParseHexString(hexValue, AlphaPosition) ?? AvaloniaProperty.UnsetValue; + } + + /// + /// Converts the given color to its hex color value string representation. + /// + /// The color to represent as a hex value string. + /// The output position of the alpha component. + /// Whether the hex symbol '#' will be added. + /// The input color converted to its hex value string. + public static string ToHexString( + Color color, + AlphaComponentPosition alphaPosition, + bool includeSymbol = false) + { + uint intColor; + if (alphaPosition == AlphaComponentPosition.Trailing) + { + intColor = ((uint)color.R << 24) | ((uint)color.G << 16) | ((uint)color.B << 8) | (uint)color.A; + } + else + { + // Default is Leading alpha + intColor = ((uint)color.A << 24) | ((uint)color.R << 16) | ((uint)color.G << 8) | (uint)color.B; + } - if (Color.TryParse(hexValue, out Color color)) + string hexColor = intColor.ToString("x8", CultureInfo.InvariantCulture).ToUpperInvariant(); + + if (includeSymbol) + { + hexColor = '#' + hexColor; + } + + return hexColor; + } + + /// + /// Parses a hex color value string into a new . + /// + /// The hex color string to parse. + /// The input position of the alpha component. + /// The parsed ; otherwise, null. + public static Color? ParseHexString( + string hexColor, + AlphaComponentPosition alphaPosition) + { + hexColor = hexColor.Trim(); + + if (!hexColor.StartsWith("#", StringComparison.Ordinal)) + { + hexColor = "#" + hexColor; + } + + if (TryParseHexFormat(hexColor.AsSpan(), alphaPosition, out Color color)) { return color; } - else if (hexValue.StartsWith("#", StringComparison.Ordinal) == false && - Color.TryParse("#" + hexValue, out Color color2)) + + return null; + } + + /// + /// Parses the given span of characters representing a hex color value into a new . + /// + /// + /// This is based on the Color.TryParseHexFormat() method. + /// It is copied because it needs to be extended to handle alpha position. + /// However, the alpha position enum is only available in the controls namespace with the ColorPicker control. + /// + private static bool TryParseHexFormat( + ReadOnlySpan s, + AlphaComponentPosition alphaPosition, + out Color color) + { + static bool TryParseCore(ReadOnlySpan input, AlphaComponentPosition alphaPosition, ref Color color) { - return color2; + var alphaComponent = 0u; + + if (input.Length == 6) + { + if (alphaPosition == AlphaComponentPosition.Trailing) + { + alphaComponent = 0x000000FF; + } + else + { + alphaComponent = 0xFF000000; + } + } + else if (input.Length != 8) + { + return false; + } + + if (!input.TryParseUInt(NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + { + return false; + } + + if (alphaComponent != 0) + { + if (alphaPosition == AlphaComponentPosition.Trailing) + { + parsed = (parsed << 8) | alphaComponent; + } + else + { + parsed = parsed | alphaComponent; + } + } + + if (alphaPosition == AlphaComponentPosition.Trailing) + { + // #RRGGBBAA + color = new Color( + a: (byte)(parsed & 0xFF), + r: (byte)((parsed >> 24) & 0xFF), + g: (byte)((parsed >> 16) & 0xFF), + b: (byte)((parsed >> 8) & 0xFF)); + } + else + { + // #AARRGGBB + color = new Color( + a: (byte)((parsed >> 24) & 0xFF), + r: (byte)((parsed >> 16) & 0xFF), + g: (byte)((parsed >> 8) & 0xFF), + b: (byte)(parsed & 0xFF)); + } + + return true; } - else + + color = default; + + ReadOnlySpan input = s.Slice(1); + + // Handle shorthand cases like #FFF (RGB) or #FFFF (ARGB). + if (input.Length == 3 || input.Length == 4) { - // Invalid hex color value provided - return AvaloniaProperty.UnsetValue; + var extendedLength = 2 * input.Length; + Span extended = stackalloc char[extendedLength]; + + for (int i = 0; i < input.Length; i++) + { + extended[2 * i + 0] = input[i]; + extended[2 * i + 1] = input[i]; + } + + return TryParseCore(extended, alphaPosition, ref color); } + + return TryParseCore(input, alphaPosition, ref color); } } } diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs b/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs index c1a03b1b77..c9801c432b 100644 --- a/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs +++ b/src/Avalonia.Controls.ColorPicker/Helpers/ColorHelper.cs @@ -1,6 +1,6 @@ using System; -using System.Globalization; using System.Collections.Generic; +using System.Globalization; using Avalonia.Media; using Avalonia.Utilities; @@ -11,8 +11,11 @@ namespace Avalonia.Controls.Primitives /// public static class ColorHelper { - private static readonly Dictionary cachedDisplayNames = new Dictionary(); - private static readonly object cacheMutex = new object(); + private static readonly Dictionary _cachedDisplayNames = new Dictionary(); + private static readonly Dictionary _cachedKnownColorNames = new Dictionary(); + private static readonly object _displayNameCacheMutex = new object(); + private static readonly object _knownColorCacheMutex = new object(); + private static readonly KnownColor[] _knownColors = (KnownColor[])Enum.GetValues(typeof(KnownColor)); /// /// Gets the relative (perceptual) luminance/brightness of the given color. @@ -59,7 +62,36 @@ namespace Avalonia.Controls.Primitives /// The approximate color display name. public static string ToDisplayName(Color color) { - // Without rounding, there are 16,777,216 possible RGB colors (without alpha). + var hsvColor = color.ToHsv(); + + // Handle extremes that are outside the below algorithm + if (color.A == 0x00) + { + return GetDisplayName(KnownColor.Transparent); + } + + // HSV ---------------------------------------------------------------------- + // + // There are far too many possible HSV colors to cache and search through + // for performance reasons. Therefore, the HSV color is rounded. + // Rounding is tolerable in this algorithm because it is perception based. + // Hue is the most important for user perception so is rounded the least. + // Then there is a lot of loss in rounding the saturation and value components + // which are not as closely related to perceived color. + // + // Hue : Round to nearest int (0..360) + // Saturation : Round to the nearest 1/10 (0..1) + // Value : Round to the nearest 1/10 (0..1) + // Alpha : Is ignored in this algorithm + // + // Rounding results in ~36_000 values to cache in the worse case. + // + // RGB ---------------------------------------------------------------------- + // + // The original algorithm worked in RGB color space. + // If this code is every adjusted to work in RGB again note the following: + // + // Without rounding, there are 16_777_216 possible RGB colors (without alpha). // This is too many to cache and search through for performance reasons. // It is also needlessly large as there are only ~140 known/named colors. // Therefore, rounding of the input color's component values is done to @@ -68,42 +100,67 @@ namespace Avalonia.Controls.Primitives // The rounding value of 5 is specially chosen. // It is a factor of 255 and therefore evenly divisible which improves // the quality of the calculations. - double rounding = 5; - var roundedColor = new Color( - 0xFF, - Convert.ToByte(Math.Round(color.R / rounding) * rounding), - Convert.ToByte(Math.Round(color.G / rounding) * rounding), - Convert.ToByte(Math.Round(color.B / rounding) * rounding)); + var roundedHsvColor = new HsvColor( + 1.0, + Math.Round(hsvColor.H, 0), + Math.Round(hsvColor.S, 1), + Math.Round(hsvColor.V, 1)); // Attempt to use a previously cached display name - lock (cacheMutex) + lock (_displayNameCacheMutex) { - if (cachedDisplayNames.TryGetValue(roundedColor, out var displayName)) + if (_cachedDisplayNames.TryGetValue(roundedHsvColor, out var displayName)) { return displayName; } } + // Build the KnownColor name cache if it doesn't already exist + lock (_knownColorCacheMutex) + { + if (_cachedKnownColorNames.Count == 0) + { + for (int i = 1; i < _knownColors.Length; i++) // Skip 'None' so start at 1 + { + KnownColor knownColor = _knownColors[i]; + + // Some known colors have the same numerical value. For example: + // - Aqua = 0xff00ffff + // - Cyan = 0xff00ffff + // + // This is not possible to represent in a dictionary which requires + // unique values. Therefore, only the first value is used. + + if (!_cachedKnownColorNames.ContainsKey(knownColor)) + { + _cachedKnownColorNames.Add(knownColor, GetDisplayName(knownColor)); + } + } + } + } + // Find the closest known color by measuring 3D Euclidean distance (ignore alpha) + // This is done in HSV color space to most closely match user-perception var closestKnownColor = KnownColor.None; var closestKnownColorDistance = double.PositiveInfinity; - var knownColors = (KnownColor[])Enum.GetValues(typeof(KnownColor)); - for (int i = 1; i < knownColors.Length; i++) // Skip 'None' + for (int i = 1; i < _knownColors.Length; i++) // Skip 'None' so start at 1 { + KnownColor knownColor = _knownColors[i]; + // Transparent is skipped since alpha is ignored making it equivalent to White - if (knownColors[i] != KnownColor.Transparent) + if (knownColor != KnownColor.Transparent) { - Color knownColor = KnownColors.ToColor(knownColors[i]); + HsvColor knownHsvColor = KnownColors.ToColor(knownColor).ToHsv(); double distance = Math.Sqrt( - Math.Pow((double)(roundedColor.R - knownColor.R), 2.0) + - Math.Pow((double)(roundedColor.G - knownColor.G), 2.0) + - Math.Pow((double)(roundedColor.B - knownColor.B), 2.0)); + Math.Pow((roundedHsvColor.H - knownHsvColor.H), 2.0) + + Math.Pow((roundedHsvColor.S - knownHsvColor.S), 2.0) + + Math.Pow((roundedHsvColor.V - knownHsvColor.V), 2.0)); if (distance < closestKnownColorDistance) { - closestKnownColor = knownColors[i]; + closestKnownColor = knownColor; closestKnownColorDistance = distance; } } @@ -113,26 +170,19 @@ namespace Avalonia.Controls.Primitives // Cache results for next time as well if (closestKnownColor != KnownColor.None) { - var sb = StringBuilderCache.Acquire(); - string name = closestKnownColor.ToString(); + string? displayName; - // Add spaces converting PascalCase to human-readable names - for (int i = 0; i < name.Length; i++) + lock (_knownColorCacheMutex) { - if (i != 0 && - char.IsUpper(name[i])) + if (!_cachedKnownColorNames.TryGetValue(closestKnownColor, out displayName)) { - sb.Append(' '); + displayName = GetDisplayName(closestKnownColor); } - - sb.Append(name[i]); } - string displayName = StringBuilderCache.GetStringAndRelease(sb); - - lock (cacheMutex) + lock (_displayNameCacheMutex) { - cachedDisplayNames.Add(roundedColor, displayName); + _cachedDisplayNames.Add(roundedHsvColor, displayName); } return displayName; @@ -142,5 +192,35 @@ namespace Avalonia.Controls.Primitives return string.Empty; } } + + /// + /// Gets the human-readable display name for the given . + /// + /// + /// This currently uses the enum value's C# name directly + /// which limits it to the EN language only. In the future this should be localized + /// to other cultures. + /// + /// The to get the display name for. + /// The human-readable display name for the given . + private static string GetDisplayName(KnownColor knownColor) + { + var sb = StringBuilderCache.Acquire(); + string name = knownColor.ToString(); + + // Add spaces converting PascalCase to human-readable names + for (int i = 0; i < name.Length; i++) + { + if (i != 0 && + char.IsUpper(name[i])) + { + sb.Append(' '); + } + + sb.Append(name[i]); + } + + return StringBuilderCache.GetStringAndRelease(sb); + } } } diff --git a/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs b/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs index 819d745772..dbd92d4ac5 100644 --- a/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs +++ b/src/Avalonia.Controls.ColorPicker/Helpers/ColorPickerHelpers.cs @@ -4,8 +4,6 @@ // Licensed to The Avalonia Project under the MIT License. using System; -using System.Collections.Generic; -using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; using Avalonia.Layout; diff --git a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml index 807e4de0b1..a9f52b93c7 100644 --- a/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml +++ b/src/Avalonia.Controls.ColorPicker/Themes/Fluent/ColorPicker.xaml @@ -42,7 +42,8 @@ - + + 20 + 20 + 10 + 10 + - + @@ -25,27 +32,28 @@