From 3f00647223dcb54281b08ae034136d427b9ce0d2 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Thu, 19 Mar 2026 14:09:22 +0000 Subject: [PATCH 01/55] Change visual brush for group box to grid (#20947) * change visual brush for group box to grid * remove unused usings * Addressed review * cleanup --- src/Avalonia.Base/Rect.cs | 1 - .../Converters/BorderGapMaskConverter.cs | 126 ++++++++++++++++++ .../Controls/GroupBox.xaml | 108 +++++++-------- .../Controls/GroupBox.xaml | 106 +++++++-------- 4 files changed, 233 insertions(+), 108 deletions(-) create mode 100644 src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs diff --git a/src/Avalonia.Base/Rect.cs b/src/Avalonia.Base/Rect.cs index 58a8c56c8b..9c901254a6 100644 --- a/src/Avalonia.Base/Rect.cs +++ b/src/Avalonia.Base/Rect.cs @@ -1,7 +1,6 @@ using System; using System.Globalization; using System.Numerics; -using Avalonia.Animation.Animators; using Avalonia.Utilities; namespace Avalonia diff --git a/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs b/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs new file mode 100644 index 0000000000..913b2f1534 --- /dev/null +++ b/src/Avalonia.Controls/Converters/BorderGapMaskConverter.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Controls.Shapes; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Avalonia.Controls.Converters +{ + // Ported from https://github.com/dotnet/wpf/blob/main/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/BorderGapMaskConverter.cs + + /// + /// Converter that generates the visual brush for + /// + public class BorderGapMaskConverter : IMultiValueConverter + { + /// + /// Convert a value. + /// + /// values as produced by source binding + /// target type + /// converter parameter + /// culture information + /// + /// Converted value. + /// Visual Brush that is used as the opacity mask for the Border + /// in the style for GroupBox. + /// + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + // + // Parameter Validation + // + if (parameter == null || + values == null || + values.Count != 3 || + values[0] is not double || + values[1] is not double || + values[2] is not double) + { + return AvaloniaProperty.UnsetValue; + } + + if (parameter is not double && parameter is not string) + { + return AvaloniaProperty.UnsetValue; + } + + // + // Conversion + // + double? headerWidth = (double?)values[0]; + double? borderWidth = (double?)values[1]; + double? borderHeight = (double?)values[2]; + + // Doesn't make sense to have a Grid + // with 0 as width or height + if (borderWidth == 0 + || borderHeight == 0) + { + return null; + } + + // Width of the line to the left of the header + // to be used to set the width of the first column of the Grid + double lineWidth; + if (parameter is string) + { + lineWidth = Double.Parse(((string)parameter), NumberFormatInfo.InvariantInfo); + } + else + { + lineWidth = (double)parameter; + } + + Grid grid = new Grid + { + Width = borderWidth ?? 0, + Height = borderHeight ?? 0 + }; + + ColumnDefinition colDef1 = new ColumnDefinition(); + ColumnDefinition colDef2 = new ColumnDefinition(); + ColumnDefinition colDef3 = new ColumnDefinition(); + colDef1.Width = new GridLength(lineWidth); + colDef2.Width = new GridLength(headerWidth ?? 0); + colDef3.Width = new GridLength(1, GridUnitType.Star); + grid.ColumnDefinitions.Add(colDef1); + grid.ColumnDefinitions.Add(colDef2); + grid.ColumnDefinitions.Add(colDef3); + RowDefinition rowDef1 = new RowDefinition(); + RowDefinition rowDef2 = new RowDefinition(); + rowDef1.Height = new GridLength((borderHeight ?? 0) / 2); + rowDef2.Height = new GridLength(1, GridUnitType.Star); + grid.RowDefinitions.Add(rowDef1); + grid.RowDefinitions.Add(rowDef2); + + Rectangle rectColumn1 = new Rectangle(); + Rectangle rectColumn2 = new Rectangle(); + Rectangle rectColumn3 = new Rectangle(); + rectColumn1.Fill = Brushes.Black; + rectColumn2.Fill = Brushes.Black; + rectColumn3.Fill = Brushes.Black; + + Grid.SetRowSpan(rectColumn1, 2); + Grid.SetRow(rectColumn1, 0); + Grid.SetColumn(rectColumn1, 0); + + Grid.SetRow(rectColumn2, 1); + Grid.SetColumn(rectColumn2, 1); + + Grid.SetRowSpan(rectColumn3, 2); + Grid.SetRow(rectColumn3, 0); + Grid.SetColumn(rectColumn3, 2); + + grid.Children.Add(rectColumn1); + grid.Children.Add(rectColumn2); + grid.Children.Add(rectColumn3); + + return (new VisualBrush(grid)); + } + } +} diff --git a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml index 4278b91e36..5b31fdb55c 100644 --- a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml @@ -1,11 +1,13 @@ + @@ -18,62 +20,60 @@ - + - - - - - - - - - - - + + + + + + + + + - - - - - - + + + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + UseLayoutRounding="{TemplateBinding UseLayoutRounding}" /> + + + @@ -84,7 +84,7 @@ + diff --git a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml index 526ba7cc77..826c3ec2fe 100644 --- a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml @@ -1,11 +1,13 @@ + @@ -16,60 +18,58 @@ - + - - - - - - - - - - - + + + + + + + + + - - - - - - + + + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + UseLayoutRounding="{TemplateBinding UseLayoutRounding}" /> + + + @@ -80,6 +80,6 @@ - + + From f16fcabd910bf6c3dbe6d34d51078e512fd21725 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Fri, 20 Mar 2026 11:08:46 +0100 Subject: [PATCH 02/55] Make RoutedEvent nullable in event arguments (#20953) --- src/Avalonia.Base/Input/DragEventArgs.cs | 2 +- src/Avalonia.Base/Input/FocusChangedEventArgs.cs | 2 +- src/Avalonia.Base/Input/FocusChangingEventArgs.cs | 2 +- src/Avalonia.Base/Input/PointerDeltaEventArgs.cs | 2 +- src/Avalonia.Base/Input/PointerEventArgs.cs | 4 ++-- src/Avalonia.Base/Input/TappedEventArgs.cs | 2 +- .../NumericUpDown/NumericUpDownValueChangedEventArgs.cs | 2 +- src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs | 2 +- src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs | 2 +- src/Avalonia.Controls/ScrollChangedEventArgs.cs | 2 +- src/Avalonia.Controls/SelectionChangedEventArgs.cs | 2 +- src/Avalonia.Controls/Spinner.cs | 4 ++-- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Avalonia.Base/Input/DragEventArgs.cs b/src/Avalonia.Base/Input/DragEventArgs.cs index e68a6138e0..8609235b2a 100644 --- a/src/Avalonia.Base/Input/DragEventArgs.cs +++ b/src/Avalonia.Base/Input/DragEventArgs.cs @@ -27,7 +27,7 @@ namespace Avalonia.Input [Unstable("This constructor might be removed in 12.0. For unit testing, consider using DragDrop.DoDragDrop or IHeadlessWindow.DragDrop.")] public DragEventArgs( - RoutedEvent routedEvent, + RoutedEvent? routedEvent, IDataTransfer dataTransfer, Interactive target, Point targetLocation, diff --git a/src/Avalonia.Base/Input/FocusChangedEventArgs.cs b/src/Avalonia.Base/Input/FocusChangedEventArgs.cs index ade599ee08..ecca7750ed 100644 --- a/src/Avalonia.Base/Input/FocusChangedEventArgs.cs +++ b/src/Avalonia.Base/Input/FocusChangedEventArgs.cs @@ -11,7 +11,7 @@ namespace Avalonia.Input /// Initializes a new instance of . /// /// The routed event associated with these event args. - public FocusChangedEventArgs(RoutedEvent routedEvent) + public FocusChangedEventArgs(RoutedEvent? routedEvent) : base(routedEvent) { } diff --git a/src/Avalonia.Base/Input/FocusChangingEventArgs.cs b/src/Avalonia.Base/Input/FocusChangingEventArgs.cs index 372ddf38b6..ed237265a6 100644 --- a/src/Avalonia.Base/Input/FocusChangingEventArgs.cs +++ b/src/Avalonia.Base/Input/FocusChangingEventArgs.cs @@ -12,7 +12,7 @@ namespace Avalonia.Input /// /// Provides data for focus changing. /// - internal FocusChangingEventArgs(RoutedEvent routedEvent) : base(routedEvent) + public FocusChangingEventArgs(RoutedEvent? routedEvent) : base(routedEvent) { } diff --git a/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs b/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs index 3c4562edf4..6c65dd8548 100644 --- a/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs +++ b/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs @@ -10,7 +10,7 @@ namespace Avalonia.Input public Vector Delta { get; } [Unstable("This constructor might be removed in 12.0.")] - public PointerDeltaEventArgs(RoutedEvent routedEvent, object? source, + public PointerDeltaEventArgs(RoutedEvent? routedEvent, object? source, IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp, PointerPointProperties properties, KeyModifiers modifiers, Vector delta) : base(routedEvent, source, pointer, rootVisual, rootVisualPosition, diff --git a/src/Avalonia.Base/Input/PointerEventArgs.cs b/src/Avalonia.Base/Input/PointerEventArgs.cs index f4bf785c56..94c81a798d 100644 --- a/src/Avalonia.Base/Input/PointerEventArgs.cs +++ b/src/Avalonia.Base/Input/PointerEventArgs.cs @@ -15,7 +15,7 @@ namespace Avalonia.Input private readonly Lazy?>? _previousPoints; [Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")] - public PointerEventArgs(RoutedEvent routedEvent, + public PointerEventArgs(RoutedEvent? routedEvent, object? source, IPointer pointer, Visual? rootVisual, Point rootVisualPosition, @@ -33,7 +33,7 @@ namespace Avalonia.Input KeyModifiers = modifiers; } - internal PointerEventArgs(RoutedEvent routedEvent, + internal PointerEventArgs(RoutedEvent? routedEvent, object? source, IPointer pointer, Visual? rootVisual, Point rootVisualPosition, diff --git a/src/Avalonia.Base/Input/TappedEventArgs.cs b/src/Avalonia.Base/Input/TappedEventArgs.cs index a235d495d6..924f4be11f 100644 --- a/src/Avalonia.Base/Input/TappedEventArgs.cs +++ b/src/Avalonia.Base/Input/TappedEventArgs.cs @@ -6,7 +6,7 @@ namespace Avalonia.Input { private readonly PointerEventArgs lastPointerEventArgs; - public TappedEventArgs(RoutedEvent routedEvent, PointerEventArgs lastPointerEventArgs) + public TappedEventArgs(RoutedEvent? routedEvent, PointerEventArgs lastPointerEventArgs) : base(routedEvent) { this.lastPointerEventArgs = lastPointerEventArgs; diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs index af835541ae..a5cc9de8b1 100644 --- a/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs +++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs @@ -4,7 +4,7 @@ namespace Avalonia.Controls { public class NumericUpDownValueChangedEventArgs : RoutedEventArgs { - public NumericUpDownValueChangedEventArgs(RoutedEvent routedEvent, decimal? oldValue, decimal? newValue) : base(routedEvent) + public NumericUpDownValueChangedEventArgs(RoutedEvent? routedEvent, decimal? oldValue, decimal? newValue) : base(routedEvent) { OldValue = oldValue; NewValue = newValue; diff --git a/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs b/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs index 083d64e6df..7c9d3de86d 100644 --- a/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs +++ b/src/Avalonia.Controls/Page/DrawerClosingEventArgs.cs @@ -11,7 +11,7 @@ namespace Avalonia.Controls /// Initializes a new instance of the class. /// /// The routed event associated with these event data. - public DrawerClosingEventArgs(RoutedEvent routedEvent) : base(routedEvent) { } + public DrawerClosingEventArgs(RoutedEvent? routedEvent) : base(routedEvent) { } /// /// Gets or sets a value indicating whether the closing should be cancelled. diff --git a/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs b/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs index 7163c2075a..e39596cccc 100644 --- a/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/Page/PageSelectionChangedEventArgs.cs @@ -13,7 +13,7 @@ namespace Avalonia.Controls /// The routed event associated with this event args instance. /// The page that was selected before the change, or if no page was selected. /// The page that is now selected, or if selection was cleared. - public PageSelectionChangedEventArgs(RoutedEvent routedEvent, Page? previousPage, Page? currentPage) + public PageSelectionChangedEventArgs(RoutedEvent? routedEvent, Page? previousPage, Page? currentPage) : base(routedEvent) { PreviousPage = previousPage; diff --git a/src/Avalonia.Controls/ScrollChangedEventArgs.cs b/src/Avalonia.Controls/ScrollChangedEventArgs.cs index fed23964f5..617d5c3f4d 100644 --- a/src/Avalonia.Controls/ScrollChangedEventArgs.cs +++ b/src/Avalonia.Controls/ScrollChangedEventArgs.cs @@ -16,7 +16,7 @@ namespace Avalonia.Controls } public ScrollChangedEventArgs( - RoutedEvent routedEvent, + RoutedEvent? routedEvent, Vector extentDelta, Vector offsetDelta, Vector viewportDelta) diff --git a/src/Avalonia.Controls/SelectionChangedEventArgs.cs b/src/Avalonia.Controls/SelectionChangedEventArgs.cs index c19d868d57..efbec0c490 100644 --- a/src/Avalonia.Controls/SelectionChangedEventArgs.cs +++ b/src/Avalonia.Controls/SelectionChangedEventArgs.cs @@ -15,7 +15,7 @@ namespace Avalonia.Controls /// The event being raised. /// The items removed from the selection. /// The items added to the selection. - public SelectionChangedEventArgs(RoutedEvent routedEvent, IList removedItems, IList addedItems) + public SelectionChangedEventArgs(RoutedEvent? routedEvent, IList removedItems, IList addedItems) : base(routedEvent) { RemovedItems = removedItems; diff --git a/src/Avalonia.Controls/Spinner.cs b/src/Avalonia.Controls/Spinner.cs index 64c0db62b1..840113acd4 100644 --- a/src/Avalonia.Controls/Spinner.cs +++ b/src/Avalonia.Controls/Spinner.cs @@ -66,7 +66,7 @@ namespace Avalonia.Controls Direction = direction; } - public SpinEventArgs(RoutedEvent routedEvent, SpinDirection direction) + public SpinEventArgs(RoutedEvent? routedEvent, SpinDirection direction) : base(routedEvent) { Direction = direction; @@ -78,7 +78,7 @@ namespace Avalonia.Controls UsingMouseWheel = usingMouseWheel; } - public SpinEventArgs(RoutedEvent routedEvent, SpinDirection direction, bool usingMouseWheel) + public SpinEventArgs(RoutedEvent? routedEvent, SpinDirection direction, bool usingMouseWheel) : base(routedEvent) { Direction = direction; From 7cd5d339e52b1c77a1d97c322fabc65f4e5e7c65 Mon Sep 17 00:00:00 2001 From: Andrej Benedik Date: Fri, 20 Mar 2026 13:05:48 +0100 Subject: [PATCH 03/55] Fixed creating RenderTarget when using Vulkan backend (#20952) * Updated VulkanContext.TryGetFeature to also provide IVulkanKhrSurfacePlatformSurfaceFactory This also fixed creating new RenderTarget when using Vulkan backend, see issue #20824 * Prevent duplicate check if _renderTarget is null --- .../Composition/Server/ServerCompositionTarget.cs | 12 ++++++++---- src/Avalonia.Vulkan/VulkanContext.cs | 6 +++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs index 81a3c09b35..e8ae84eb03 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs @@ -149,12 +149,16 @@ namespace Avalonia.Rendering.Composition.Server try { - if (_renderTarget == null && !_compositor.IsReadyToCreateRenderTarget(_surfaces())) + if (_renderTarget == null) { - IsWaitingForReadyRenderTarget = IsEnabled; - return; + if (!_compositor.IsReadyToCreateRenderTarget(_surfaces())) + { + IsWaitingForReadyRenderTarget = IsEnabled; + return; + } + + _renderTarget = _compositor.CreateRenderTarget(_surfaces()); } - _renderTarget ??= _compositor.CreateRenderTarget(_surfaces()); } catch (RenderTargetNotReadyException) { diff --git a/src/Avalonia.Vulkan/VulkanContext.cs b/src/Avalonia.Vulkan/VulkanContext.cs index 6667055e8c..8691e55a05 100644 --- a/src/Avalonia.Vulkan/VulkanContext.cs +++ b/src/Avalonia.Vulkan/VulkanContext.cs @@ -41,6 +41,10 @@ internal class VulkanContext : IVulkanPlatformGraphicsContext { if (featureType == typeof(IVulkanContextExternalObjectsFeature)) return _externalObjectsFeature; + + if (featureType == typeof(IVulkanKhrSurfacePlatformSurfaceFactory)) + return _surfaceFactory; + return null; } @@ -71,4 +75,4 @@ internal class VulkanContext : IVulkanPlatformGraphicsContext throw new VulkanException("Unable to find a suitable platform surface"); } -} \ No newline at end of file +} From 0ce664fae07fbe4cfe9f5be57b50a9b7ebed462b Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Fri, 20 Mar 2026 14:59:17 +0100 Subject: [PATCH 04/55] Remove Unstable from several RoutedEventArgs (#20955) --- src/Avalonia.Base/Input/DragEventArgs.cs | 2 -- .../Input/PointerDeltaEventArgs.cs | 4 ---- src/Avalonia.Base/Input/PointerEventArgs.cs | 19 +++++++------------ .../Input/PointerWheelEventArgs.cs | 8 +------- 4 files changed, 8 insertions(+), 25 deletions(-) diff --git a/src/Avalonia.Base/Input/DragEventArgs.cs b/src/Avalonia.Base/Input/DragEventArgs.cs index 8609235b2a..d4e0cb1bce 100644 --- a/src/Avalonia.Base/Input/DragEventArgs.cs +++ b/src/Avalonia.Base/Input/DragEventArgs.cs @@ -1,6 +1,5 @@ using System; using Avalonia.Interactivity; -using Avalonia.Metadata; namespace Avalonia.Input { @@ -25,7 +24,6 @@ namespace Avalonia.Input return _target.TranslatePoint(_targetLocation, relativeTo) ?? new Point(0, 0); } - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using DragDrop.DoDragDrop or IHeadlessWindow.DragDrop.")] public DragEventArgs( RoutedEvent? routedEvent, IDataTransfer dataTransfer, diff --git a/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs b/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs index 6c65dd8548..6a6c61d315 100644 --- a/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs +++ b/src/Avalonia.Base/Input/PointerDeltaEventArgs.cs @@ -1,7 +1,4 @@ -using System; using Avalonia.Interactivity; -using Avalonia.Metadata; -using Avalonia.VisualTree; namespace Avalonia.Input { @@ -9,7 +6,6 @@ namespace Avalonia.Input { public Vector Delta { get; } - [Unstable("This constructor might be removed in 12.0.")] public PointerDeltaEventArgs(RoutedEvent? routedEvent, object? source, IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp, PointerPointProperties properties, KeyModifiers modifiers, Vector delta) diff --git a/src/Avalonia.Base/Input/PointerEventArgs.cs b/src/Avalonia.Base/Input/PointerEventArgs.cs index 94c81a798d..9285e65fa5 100644 --- a/src/Avalonia.Base/Input/PointerEventArgs.cs +++ b/src/Avalonia.Base/Input/PointerEventArgs.cs @@ -14,7 +14,6 @@ namespace Avalonia.Input private readonly PointerPointProperties _properties; private readonly Lazy?>? _previousPoints; - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")] public PointerEventArgs(RoutedEvent? routedEvent, object? source, IPointer pointer, @@ -32,8 +31,9 @@ namespace Avalonia.Input Timestamp = timestamp; KeyModifiers = modifiers; } - - internal PointerEventArgs(RoutedEvent? routedEvent, + + [PrivateApi] + public PointerEventArgs(RoutedEvent? routedEvent, object? source, IPointer pointer, Visual? rootVisual, Point rootVisualPosition, @@ -41,9 +41,7 @@ namespace Avalonia.Input PointerPointProperties properties, KeyModifiers modifiers, Lazy?>? previousPoints) -#pragma warning disable CS0618 : this(routedEvent, source, pointer, rootVisual, rootVisualPosition, timestamp, properties, modifiers) -#pragma warning restore CS0618 { _previousPoints = previousPoints; } @@ -160,9 +158,8 @@ namespace Avalonia.Input public class PointerPressedEventArgs : PointerEventArgs { - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")] public PointerPressedEventArgs( - object source, + object? source, IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp, @@ -180,9 +177,8 @@ namespace Avalonia.Input public class PointerReleasedEventArgs : PointerEventArgs { - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow mouse methods.")] public PointerReleasedEventArgs( - object source, IPointer pointer, + object? source, IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp, PointerPointProperties properties, KeyModifiers modifiers, MouseButton initialPressMouseButton) @@ -202,8 +198,7 @@ namespace Avalonia.Input { public IPointer Pointer { get; } - [Unstable("This constructor might be removed in 12.0. If you need to remove capture, use stable methods on the IPointer instance.")] - public PointerCaptureLostEventArgs(object source, IPointer pointer) : base(InputElement.PointerCaptureLostEvent) + public PointerCaptureLostEventArgs(object? source, IPointer pointer) : base(InputElement.PointerCaptureLostEvent) { Pointer = pointer; Source = source; @@ -216,7 +211,7 @@ namespace Avalonia.Input public CaptureSource CaptureSource { get; } public IInputElement? NewValue { get; } - internal PointerCaptureChangingEventArgs(object source, IPointer pointer, IInputElement? newValue, CaptureSource captureSource) : base(InputElement.PointerCaptureChangingEvent) + internal PointerCaptureChangingEventArgs(object? source, IPointer pointer, IInputElement? newValue, CaptureSource captureSource) : base(InputElement.PointerCaptureChangingEvent) { Pointer = pointer; Source = source; diff --git a/src/Avalonia.Base/Input/PointerWheelEventArgs.cs b/src/Avalonia.Base/Input/PointerWheelEventArgs.cs index 22624a61dd..9cfeaeea74 100644 --- a/src/Avalonia.Base/Input/PointerWheelEventArgs.cs +++ b/src/Avalonia.Base/Input/PointerWheelEventArgs.cs @@ -1,16 +1,10 @@ -using System; -using Avalonia.Interactivity; -using Avalonia.Metadata; -using Avalonia.VisualTree; - namespace Avalonia.Input { public class PointerWheelEventArgs : PointerEventArgs { public Vector Delta { get; } - [Unstable("This constructor might be removed in 12.0. For unit testing, consider using IHeadlessWindow.MouseWheel.")] - public PointerWheelEventArgs(object source, IPointer pointer, Visual rootVisual, + public PointerWheelEventArgs(object? source, IPointer pointer, Visual rootVisual, Point rootVisualPosition, ulong timestamp, PointerPointProperties properties, KeyModifiers modifiers, Vector delta) : base(InputElement.PointerWheelChangedEvent, source, pointer, rootVisual, rootVisualPosition, From 3068850405b706e3b48626f5157d828a7466cc5c Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Fri, 20 Mar 2026 15:51:01 +0100 Subject: [PATCH 05/55] Make Dispatcher.Yield use the current dispatcher (#20956) --- .../Threading/Dispatcher.Invoke.cs | 5 +- .../DispatcherTests.cs | 75 ++++++++----------- 2 files changed, 31 insertions(+), 49 deletions(-) diff --git a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs index fbd96ed7d8..90167fa8a1 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs @@ -737,9 +737,6 @@ public partial class Dispatcher /// public static DispatcherPriorityAwaitable Yield(DispatcherPriority priority) { - // TODO12: Update to use Dispatcher.CurrentDispatcher once multi-dispatcher support is merged - var current = UIThread; - current.VerifyAccess(); - return UIThread.Resume(priority); + return CurrentDispatcher.Resume(priority); } } diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index 5596761b7c..ffb94cc5d6 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -512,76 +512,59 @@ public partial class DispatcherTests [Fact] - public async Task DispatcherResumeContinuesOnUIThread() + public async Task DispatcherResumeContinuesOnCurrentThread() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); - var workload = Dispatcher.UIThread.InvokeAsync( + var dispatcher = Dispatcher.CurrentDispatcher; + + var workload = dispatcher.InvokeAsync( async () => { - Assert.True(Dispatcher.UIThread.CheckAccess()); + Assert.True(dispatcher.CheckAccess()); await Task.Delay(1).ConfigureAwait(false); - Assert.False(Dispatcher.UIThread.CheckAccess()); + Assert.False(dispatcher.CheckAccess()); - await Dispatcher.UIThread.Resume(); - Assert.True(Dispatcher.UIThread.CheckAccess()); + await dispatcher.Resume(); + Assert.True(dispatcher.CheckAccess()); tokenSource.Cancel(); }); - Dispatcher.UIThread.MainLoop(tokenSource.Token); + dispatcher.MainLoop(tokenSource.Token); } [Fact] - public async Task DispatcherYieldContinuesOnUIThread() + public async Task DispatcherYieldContinuesOnCurrentThread() { using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); - var workload = Dispatcher.UIThread.InvokeAsync( - async () => - { - Assert.True(Dispatcher.UIThread.CheckAccess()); - - await Dispatcher.Yield(); - Assert.True(Dispatcher.UIThread.CheckAccess()); - - tokenSource.Cancel(); - }); - - Dispatcher.UIThread.MainLoop(tokenSource.Token); - } - - [Fact] - public async Task DispatcherYieldThrowsOnNonUIThread() - { - using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + var dispatcher = Dispatcher.CurrentDispatcher; - var tokenSource = new CancellationTokenSource(); - var workload = Dispatcher.UIThread.InvokeAsync( + var workload = dispatcher.InvokeAsync( async () => { - Assert.True(Dispatcher.UIThread.CheckAccess()); + Assert.True(dispatcher.CheckAccess()); - await Task.Delay(1).ConfigureAwait(false); - Assert.False(Dispatcher.UIThread.CheckAccess()); - await Assert.ThrowsAsync(async () => await Dispatcher.Yield()); + await Dispatcher.Yield(); + Assert.True(dispatcher.CheckAccess()); tokenSource.Cancel(); }); - Dispatcher.UIThread.MainLoop(tokenSource.Token); + dispatcher.MainLoop(tokenSource.Token); } [Fact] - public async Task AwaitWithPriorityRunsOnUIThread() + public async Task AwaitWithPriorityRunsOnCurrentThread() { - static async Task Workload() + static async Task Workload(Dispatcher dispatcher) { await Task.Delay(1).ConfigureAwait(false); - Assert.False(Dispatcher.UIThread.CheckAccess()); + Assert.False(dispatcher.CheckAccess()); return Thread.CurrentThread.ManagedThreadId; } @@ -589,25 +572,27 @@ public partial class DispatcherTests using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); var tokenSource = new CancellationTokenSource(); - var workload = Dispatcher.UIThread.InvokeAsync( + var dispatcher = Dispatcher.CurrentDispatcher; + + var workload = dispatcher.InvokeAsync( async () => { - Assert.True(Dispatcher.UIThread.CheckAccess()); - Task taskWithoutResult = Workload(); + Assert.True(dispatcher.CheckAccess()); + Task taskWithoutResult = Workload(dispatcher); - await Dispatcher.UIThread.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default); + await dispatcher.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default); - Assert.True(Dispatcher.UIThread.CheckAccess()); - Task taskWithResult = Workload(); + Assert.True(dispatcher.CheckAccess()); + Task taskWithResult = Workload(dispatcher); - await Dispatcher.UIThread.AwaitWithPriority(taskWithResult, DispatcherPriority.Default); + await dispatcher.AwaitWithPriority(taskWithResult, DispatcherPriority.Default); - Assert.True(Dispatcher.UIThread.CheckAccess()); + Assert.True(dispatcher.CheckAccess()); tokenSource.Cancel(); }); - Dispatcher.UIThread.MainLoop(tokenSource.Token); + dispatcher.MainLoop(tokenSource.Token); } private class AsyncLocalTestClass From 658afb87173081b9444d30dd70ae47a04e519e4e Mon Sep 17 00:00:00 2001 From: Jumar Macato <16554748+jmacato@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:00:46 +0800 Subject: [PATCH 06/55] Animation PlaybackBehavior Implementation and Tests. (#20966) * Animation PlaybackBehavior Implementation and Tests. * fix review * Fix review comments. * Add isManuallyStarted to IAnimation interface --- src/Avalonia.Base/Animation/Animation.cs | 60 +++- .../Animation/AnimationInstance`1.cs | 44 +-- .../Animation/Animators/Animator`1.cs | 9 +- .../Animation/Animators/BaseBrushAnimator.cs | 6 +- .../Animation/Animators/TransformAnimator.cs | 9 +- .../DisposeAnimationInstanceSubject.cs | 24 +- src/Avalonia.Base/Animation/IAnimation.cs | 3 +- src/Avalonia.Base/Animation/IAnimator.cs | 5 +- .../Animation/PlaybackBehavior.cs | 29 ++ .../Media/Effects/EffectAnimator.cs | 6 +- src/Avalonia.Base/Styling/StyleInstance.cs | 3 +- .../Animation/AnimationIterationTests.cs | 310 +++++++++++++++++- 12 files changed, 444 insertions(+), 64 deletions(-) create mode 100644 src/Avalonia.Base/Animation/PlaybackBehavior.cs diff --git a/src/Avalonia.Base/Animation/Animation.cs b/src/Avalonia.Base/Animation/Animation.cs index 0391280ede..5f15f14534 100644 --- a/src/Avalonia.Base/Animation/Animation.cs +++ b/src/Avalonia.Base/Animation/Animation.cs @@ -42,6 +42,15 @@ namespace Avalonia.Animation o => o._playbackDirection, (o, v) => o._playbackDirection = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty PlaybackBehaviorProperty = + AvaloniaProperty.RegisterDirect( + nameof(PlaybackBehavior), + o => o._playbackBehavior, + (o, v) => o._playbackBehavior = v); + /// /// Defines the property. /// @@ -91,6 +100,7 @@ namespace Avalonia.Animation private TimeSpan _duration; private IterationCount _iterationCount = new IterationCount(1); private PlaybackDirection _playbackDirection; + private PlaybackBehavior _playbackBehavior; private FillMode _fillMode; private Easing _easing = new LinearEasing(); private TimeSpan _delay = TimeSpan.Zero; @@ -124,6 +134,19 @@ namespace Avalonia.Animation set { SetAndRaise(PlaybackDirectionProperty, ref _playbackDirection, value); } } + /// + /// Gets or sets the playback behavior for this animation. + /// When set to , manually started animations and + /// animations targeting always play, + /// while style-applied animations pause when the control is not effectively visible + /// (see ). + /// + public PlaybackBehavior PlaybackBehavior + { + get { return _playbackBehavior; } + set { SetAndRaise(PlaybackBehaviorProperty, ref _playbackBehavior, value); } + } + /// /// Gets or sets the value fill mode for this animation. /// @@ -192,11 +215,12 @@ namespace Avalonia.Animation return null; } - private (IList Animators, IList subscriptions) InterpretKeyframes(Animatable control) + private (IList Animators, IList subscriptions, bool animatesVisibility) InterpretKeyframes(Animatable control) { var handlerList = new Dictionary<(Type type, AvaloniaProperty Property), Func>(); var animatorKeyFrames = new List(); var subscriptions = new List(); + var animatesVisibility = false; foreach (var keyframe in Children) { @@ -207,6 +231,9 @@ namespace Avalonia.Animation throw new InvalidOperationException("No Setter property assigned."); } + if (setter.Property == Visual.IsVisibleProperty) + animatesVisibility = true; + var handler = Animation.GetAnimator(setter) ?? GetAnimatorType(setter.Property); if (handler == null) @@ -265,19 +292,31 @@ namespace Avalonia.Animation } } - return (newAnimatorInstances, subscriptions); + return (newAnimatorInstances, subscriptions, animatesVisibility); } - IDisposable IAnimation.Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete) - => Apply(control, clock, match, onComplete); - + IDisposable IAnimation.Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete, + bool isManuallyStarted) + => Apply(control, clock, match, onComplete, isManuallyStarted); + /// - internal IDisposable Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete) + internal IDisposable Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete, + bool isManuallyStarted = false) { - var (animators, subscriptions) = InterpretKeyframes(control); + var (animators, subscriptions, animatesVisibility) = InterpretKeyframes(control); + + var shouldPauseOnInvisible = _playbackBehavior switch + { + PlaybackBehavior.Auto => !(animatesVisibility || isManuallyStarted), + PlaybackBehavior.Always => false, + PlaybackBehavior.OnlyIfVisible => true, + _ => throw new InvalidOperationException($"Unknown PlaybackBehavior value: {_playbackBehavior}"), + }; + if (animators.Count == 1) { - var subscription = animators[0].Apply(this, control, clock, match, onComplete); + var subscription = animators[0].Apply(this, control, clock, match, + onComplete, shouldPauseOnInvisible); if (subscription is not null) { @@ -297,7 +336,8 @@ namespace Avalonia.Animation completionTasks!.Add(tcs.Task); } - var subscription = animator.Apply(this, control, clock, match, animatorOnComplete); + var subscription = animator.Apply(this, control, clock, match, + animatorOnComplete, shouldPauseOnInvisible); if (subscription is not null) { @@ -348,7 +388,7 @@ namespace Avalonia.Animation run.TrySetResult(null); subscriptions?.Dispose(); cancellation?.Dispose(); - }); + }, isManuallyStarted: true); cancellation = cancellationToken.Register(() => { diff --git a/src/Avalonia.Base/Animation/AnimationInstance`1.cs b/src/Avalonia.Base/Animation/AnimationInstance`1.cs index 6358dd2c6b..390a4a10b4 100644 --- a/src/Avalonia.Base/Animation/AnimationInstance`1.cs +++ b/src/Avalonia.Base/Animation/AnimationInstance`1.cs @@ -38,7 +38,9 @@ namespace Avalonia.Animation private EventHandler? _visibilityChangedHandler; private EventHandler? _detachedHandler; - public AnimationInstance(Animation animation, Animatable control, Animator animator, IClock baseClock, Action? OnComplete, Func Interpolator) + private readonly bool _shouldPauseOnInvisible; + + public AnimationInstance(Animation animation, Animatable control, Animator animator, IClock baseClock, Action? OnComplete, Func Interpolator, bool shouldPauseOnInvisible) { _animator = animator; _animation = animation; @@ -49,6 +51,7 @@ namespace Avalonia.Animation _lastInterpValue = default!; _firstKFValue = default!; _neutralValue = default!; + _shouldPauseOnInvisible = shouldPauseOnInvisible; FetchProperties(); } @@ -120,26 +123,29 @@ namespace Avalonia.Animation if (_targetControl is Visual visual) { - _visibilityChangedHandler = (_, _) => + if (_shouldPauseOnInvisible) { - if (_clock is null || _clock.PlayState == PlayState.Stop) - return; - if (visual.IsEffectivelyVisible) - { - if (_clock.PlayState == PlayState.Pause) - _clock.PlayState = PlayState.Run; - } - else + _visibilityChangedHandler = (_, _) => { - if (_clock.PlayState == PlayState.Run) - _clock.PlayState = PlayState.Pause; - } - }; - visual.IsEffectivelyVisibleChanged += _visibilityChangedHandler; - - // If already invisible when animation starts, pause immediately. - if (!visual.IsEffectivelyVisible) - _clock.PlayState = PlayState.Pause; + if (_clock is null || _clock.PlayState == PlayState.Stop) + return; + if (visual.IsEffectivelyVisible) + { + if (_clock.PlayState == PlayState.Pause) + _clock.PlayState = PlayState.Run; + } + else + { + if (_clock.PlayState == PlayState.Run) + _clock.PlayState = PlayState.Pause; + } + }; + visual.IsEffectivelyVisibleChanged += _visibilityChangedHandler; + + // If already invisible when animation starts, pause immediately. + if (!visual.IsEffectivelyVisible) + _clock.PlayState = PlayState.Pause; + } // Stop and dispose the animation when detached from the visual tree. _detachedHandler = (_, _) => DoComplete(); diff --git a/src/Avalonia.Base/Animation/Animators/Animator`1.cs b/src/Avalonia.Base/Animation/Animators/Animator`1.cs index 954b62f9bc..20311c8389 100644 --- a/src/Avalonia.Base/Animation/Animators/Animator`1.cs +++ b/src/Avalonia.Base/Animation/Animators/Animator`1.cs @@ -17,9 +17,9 @@ namespace Avalonia.Animation.Animators public AvaloniaProperty? Property { get; set; } /// - public virtual IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable match, Action? onComplete) + public virtual IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable match, Action? onComplete, bool shouldPauseOnInvisible) { - var subject = new DisposeAnimationInstanceSubject(this, animation, control, clock, onComplete); + var subject = new DisposeAnimationInstanceSubject(this, animation, control, clock, onComplete, shouldPauseOnInvisible); return new CompositeDisposable(match.Subscribe(subject), subject); } @@ -103,7 +103,7 @@ namespace Avalonia.Animation.Animators /// /// Runs the KeyFrames Animation. /// - internal IDisposable Run(Animation animation, Animatable control, IClock? clock, Action? onComplete) + internal IDisposable Run(Animation animation, Animatable control, IClock? clock, Action? onComplete, bool shouldPauseOnInvisible) { var instance = new AnimationInstance( animation, @@ -111,7 +111,8 @@ namespace Avalonia.Animation.Animators this, clock ?? control.Clock ?? Clock.GlobalClock, onComplete, - InterpolationHandler); + InterpolationHandler, + shouldPauseOnInvisible); return BindAnimation(control, instance); } diff --git a/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs b/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs index c81be67060..e18b98ad26 100644 --- a/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs +++ b/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs @@ -38,20 +38,20 @@ namespace Avalonia.Animation.Animators /// public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, - IObservable match, Action? onComplete) + IObservable match, Action? onComplete, bool shouldPauseOnInvisible) { if (TryCreateCustomRegisteredAnimator(out var animator) || TryCreateGradientAnimator(out animator) || TryCreateSolidColorBrushAnimator(out animator)) { - return animator.Apply(animation, control, clock, match, onComplete); + return animator.Apply(animation, control, clock, match, onComplete, shouldPauseOnInvisible); } Logger.TryGet(LogEventLevel.Error, LogArea.Animations)?.Log( this, "The animation's keyframe value types set is not supported."); - return base.Apply(animation, control, clock, match, onComplete); + return base.Apply(animation, control, clock, match, onComplete, shouldPauseOnInvisible); } /// diff --git a/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs b/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs index b1fc720e6a..aef5c78f95 100644 --- a/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs +++ b/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs @@ -14,7 +14,8 @@ namespace Avalonia.Animation.Animators DoubleAnimator? _doubleAnimator; /// - public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable obsMatch, Action? onComplete) + public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, + IObservable obsMatch, Action? onComplete, bool shouldPauseOnInvisible) { var ctrl = (Visual)control; @@ -65,7 +66,8 @@ namespace Avalonia.Animation.Animators // It's a transform object so let's target that. if (renderTransformType == Property.OwnerType) { - return _doubleAnimator.Apply(animation, (Transform) ctrl.RenderTransform, clock ?? control.Clock, obsMatch, onComplete); + return _doubleAnimator.Apply(animation, (Transform) ctrl.RenderTransform, clock ?? control.Clock, + obsMatch, onComplete, shouldPauseOnInvisible); } // It's a TransformGroup and try finding the target there. else if (renderTransformType == typeof(TransformGroup)) @@ -74,7 +76,8 @@ namespace Avalonia.Animation.Animators { if (transform.GetType() == Property.OwnerType) { - return _doubleAnimator.Apply(animation, transform, clock ?? control.Clock, obsMatch, onComplete); + return _doubleAnimator.Apply(animation, transform, clock ?? control.Clock, + obsMatch, onComplete, shouldPauseOnInvisible); } } } diff --git a/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs b/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs index af25766289..7d355cc77c 100644 --- a/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs +++ b/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs @@ -6,24 +6,18 @@ namespace Avalonia.Animation /// /// Manages the lifetime of animation instances as determined by its selector state. /// - internal class DisposeAnimationInstanceSubject : IObserver, IDisposable + internal class DisposeAnimationInstanceSubject( + Animator animator, + Animation animation, + Animatable control, + IClock? clock, + Action? onComplete, + bool shouldPauseOnInvisible) + : IObserver, IDisposable { private IDisposable? _lastInstance; private bool _lastMatch; - private readonly Animator _animator; - private readonly Animation _animation; - private readonly Animatable _control; - private readonly Action? _onComplete; - private readonly IClock? _clock; - public DisposeAnimationInstanceSubject(Animator animator, Animation animation, Animatable control, IClock? clock, Action? onComplete) - { - this._animator = animator; - this._animation = animation; - this._control = control; - this._onComplete = onComplete; - this._clock = clock; - } public void Dispose() { _lastInstance?.Dispose(); @@ -47,7 +41,7 @@ namespace Avalonia.Animation if (matchVal) { - _lastInstance = _animator.Run(_animation, _control, _clock, _onComplete); + _lastInstance = animator.Run(animation, control, clock, onComplete, shouldPauseOnInvisible); } else { diff --git a/src/Avalonia.Base/Animation/IAnimation.cs b/src/Avalonia.Base/Animation/IAnimation.cs index a5b8b75b12..53146a3edd 100644 --- a/src/Avalonia.Base/Animation/IAnimation.cs +++ b/src/Avalonia.Base/Animation/IAnimation.cs @@ -14,7 +14,8 @@ namespace Avalonia.Animation /// /// Apply the animation to the specified control and run it when produces true. /// - internal IDisposable Apply(Animatable control, IClock? clock, IObservable match, Action? onComplete = null); + internal IDisposable Apply(Animatable control, IClock? clock, IObservable match, + Action? onComplete = null, bool isManuallyStarted = false); /// /// Run the animation on the specified control. diff --git a/src/Avalonia.Base/Animation/IAnimator.cs b/src/Avalonia.Base/Animation/IAnimator.cs index 9fed7be9be..f065ad3730 100644 --- a/src/Avalonia.Base/Animation/IAnimator.cs +++ b/src/Avalonia.Base/Animation/IAnimator.cs @@ -12,11 +12,12 @@ namespace Avalonia.Animation /// /// The target property. /// - AvaloniaProperty? Property {get; set;} + AvaloniaProperty? Property { get; set; } /// /// Applies the current KeyFrame group to the specified control. /// - IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable match, Action? onComplete); + IDisposable? Apply(Animation animation, Animatable control, IClock? clock, + IObservable match, Action? onComplete, bool shouldPauseOnInvisible); } } diff --git a/src/Avalonia.Base/Animation/PlaybackBehavior.cs b/src/Avalonia.Base/Animation/PlaybackBehavior.cs new file mode 100644 index 0000000000..4dcfd5c784 --- /dev/null +++ b/src/Avalonia.Base/Animation/PlaybackBehavior.cs @@ -0,0 +1,29 @@ +namespace Avalonia.Animation +{ + /// + /// Determines whether an animation pauses when its target control is not effectively visible + /// (see ). + /// + public enum PlaybackBehavior + { + /// + /// The system decides based on context. Manually started animations + /// (via ) + /// and animations that target always play. + /// Style-applied animations pause when the control is not effectively visible + /// (see ). + /// + Auto, + + /// + /// The animation always plays regardless of the control's effective visibility state. + /// + Always, + + /// + /// The animation pauses when the control is not effectively visible + /// (see ). + /// + OnlyIfVisible, + } +} diff --git a/src/Avalonia.Base/Media/Effects/EffectAnimator.cs b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs index e2c4cc096c..a81e58f2d0 100644 --- a/src/Avalonia.Base/Media/Effects/EffectAnimator.cs +++ b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs @@ -10,17 +10,17 @@ namespace Avalonia.Animation.Animators; internal class EffectAnimator : Animator { public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock, - IObservable match, Action? onComplete) + IObservable match, Action? onComplete, bool shouldPauseOnInvisible) { if (TryCreateAnimator(out var animator) || TryCreateAnimator(out animator)) - return animator.Apply(animation, control, clock, match, onComplete); + return animator.Apply(animation, control, clock, match, onComplete, shouldPauseOnInvisible); Logger.TryGet(LogEventLevel.Error, LogArea.Animations)?.Log( this, "The animation's keyframe value types set is not supported."); - return base.Apply(animation, control, clock, match, onComplete); + return base.Apply(animation, control, clock, match, onComplete, shouldPauseOnInvisible); } private bool TryCreateAnimator([NotNullWhen(true)] out IAnimator? animator) diff --git a/src/Avalonia.Base/Styling/StyleInstance.cs b/src/Avalonia.Base/Styling/StyleInstance.cs index c397aef8c6..1ecaf3734a 100644 --- a/src/Avalonia.Base/Styling/StyleInstance.cs +++ b/src/Avalonia.Base/Styling/StyleInstance.cs @@ -72,7 +72,8 @@ namespace Avalonia.Styling _animationTrigger ??= new LightweightSubject(); _animationApplyDisposables ??= new List(); foreach (var animation in _animations) - _animationApplyDisposables.Add(animation.Apply(animatable, null, _animationTrigger)); + _animationApplyDisposables.Add(animation.Apply(animatable, null, _animationTrigger, + onComplete: null, isManuallyStarted: false)); if (_activator is null) _animationTrigger.OnNext(true); diff --git a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs index 752c1b166b..0ca5a3be6a 100644 --- a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs @@ -95,7 +95,7 @@ namespace Avalonia.Base.UnitTests.Animation } [Fact] - public void Pause_Animation_When_IsEffectivelyVisible_Is_False() + public void OnlyIfVisible_Pauses_Animation_When_IsEffectivelyVisible_Is_False() { var keyframe1 = new KeyFrame() { @@ -117,6 +117,9 @@ namespace Avalonia.Base.UnitTests.Animation Delay = TimeSpan.FromSeconds(3), DelayBetweenIterations = TimeSpan.FromSeconds(3), IterationCount = new IterationCount(2), + // Explicit opt-in: RunAsync (manual) with Auto resolves to Always, + // but this test specifically exercises the pause-on-invisible feature. + PlaybackBehavior = PlaybackBehavior.OnlyIfVisible, Children = { keyframe1, keyframe2, keyframe3 } }; @@ -158,7 +161,7 @@ namespace Avalonia.Base.UnitTests.Animation } [Fact] - public void Pause_Animation_When_IsEffectivelyVisible_Is_False_Nested() + public void OnlyIfVisible_Pauses_Animation_When_IsEffectivelyVisible_Is_False_Nested() { var keyframe1 = new KeyFrame() { @@ -180,6 +183,9 @@ namespace Avalonia.Base.UnitTests.Animation Delay = TimeSpan.FromSeconds(3), DelayBetweenIterations = TimeSpan.FromSeconds(3), IterationCount = new IterationCount(2), + // Explicit opt-in: RunAsync (manual) with Auto resolves to Always, + // but this test specifically exercises the pause-on-invisible feature. + PlaybackBehavior = PlaybackBehavior.OnlyIfVisible, Children = { keyframe1, keyframe2, keyframe3 } }; @@ -262,7 +268,7 @@ namespace Avalonia.Base.UnitTests.Animation } [Fact] - public void Pause_Animation_When_Control_Starts_Invisible() + public void OnlyIfVisible_Pauses_Animation_When_Control_Starts_Invisible() { var keyframe1 = new KeyFrame() { @@ -276,6 +282,9 @@ namespace Avalonia.Base.UnitTests.Animation { Duration = TimeSpan.FromSeconds(3), IterationCount = new IterationCount(1), + // Explicit opt-in: RunAsync (manual) with Auto resolves to Always, + // but this test specifically exercises the pause-on-invisible feature. + PlaybackBehavior = PlaybackBehavior.OnlyIfVisible, Children = { keyframe2, keyframe1 } }; @@ -960,6 +969,301 @@ namespace Avalonia.Base.UnitTests.Animation } } + [Fact] + public void Animation_Can_Set_IsVisible_True_On_Invisible_Control() + { + // Reproduces a bug where an expand animation tries to make a collapsed + // (invisible) control visible at Cue 0.0, but the animation system pauses + // animations on invisible controls, creating a deadlock where the animation + // can't run to set IsVisible=true because the control is already invisible. + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + } + } + }; + + // Control starts invisible (collapsed state). + var border = new Border() { IsVisible = false }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + // Kick off the animation. + clock.Step(TimeSpan.Zero); + + // The Cue 0.0 keyframe should have set IsVisible = true, + // even though the control started invisible. + Assert.True(border.IsVisible); + + // Animation should progress to completion. + clock.Step(TimeSpan.FromSeconds(0.3)); + Assert.True(animationRun.IsCompleted); + } + + [Fact] + public void Width_Animation_Resumes_After_IsVisible_Set_True_On_Invisible_Control() + { + // Tests the expand scenario with OnlyIfVisible: the control starts invisible + // and the animation is paused. Once IsVisible is set to true externally, + // the animation resumes and completes. + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + Easing = new LinearEasing(), + FillMode = FillMode.Forward, + PlaybackBehavior = PlaybackBehavior.OnlyIfVisible, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Layoutable.WidthProperty, 0d) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Layoutable.WidthProperty, 100d) } + } + } + }; + + // Control starts invisible (collapsed state). + var border = new Border() { Width = 0d, IsVisible = false }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + // Animation is paused because control is invisible. + clock.Step(TimeSpan.Zero); + Assert.Equal(0d, border.Width); + Assert.False(animationRun.IsCompleted); + + // Simulate what the expand handler does: set IsVisible = true externally. + border.IsVisible = true; + + // The animation should now resume and complete. + clock.Step(TimeSpan.FromSeconds(0.3)); + Assert.True(animationRun.IsCompleted); + Assert.Equal(100d, border.Width); + } + + [Fact] + public void Animation_Can_Set_IsVisible_False_At_End_Without_Pausing_Itself() + { + // An animation that sets IsVisible=false at Cue 1.0 should complete normally. + // The visibility change at the final keyframe should not cause the animation + // to pause before it can report completion. + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Visual.IsVisibleProperty, false) } + } + } + }; + + // Control starts visible (expanded state). + var border = new Border() { IsVisible = true }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + clock.Step(TimeSpan.Zero); + Assert.True(border.IsVisible); + + // Step to the end: animation sets IsVisible=false. + clock.Step(TimeSpan.FromSeconds(0.3)); + + // Animation should have completed and the final value should hold. + Assert.True(animationRun.IsCompleted); + Assert.False(border.IsVisible); + } + + [Fact] + public async Task Cancelling_Expand_Animation_Mid_Flight_Then_Collapsing_Works() + { + // Reproduces the scenario where a user rapidly toggles expand/collapse: + // the first animation is cancelled and a new one starts in the opposite direction. + // Uses single-property animations to isolate the visibility behavior. + var expandAnimation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + } + } + }; + + var collapseAnimation = new Animation() + { + Duration = TimeSpan.FromSeconds(0.3), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame() + { + Cue = new Cue(0.0), + Setters = { new Setter(Visual.IsVisibleProperty, true) } + }, + new KeyFrame() + { + Cue = new Cue(1.0), + Setters = { new Setter(Visual.IsVisibleProperty, false) } + } + } + }; + + var border = new Border() { IsVisible = false }; + + // Start expand. + var cts1 = new CancellationTokenSource(); + var clock1 = new TestClock(); + var expandRun = expandAnimation.RunAsync(border, clock1, cts1.Token); + + clock1.Step(TimeSpan.Zero); + Assert.True(border.IsVisible); + + // Partially through expand, cancel and start collapse. + clock1.Step(TimeSpan.FromSeconds(0.15)); + cts1.Cancel(); + await expandRun; + + var cts2 = new CancellationTokenSource(); + var clock2 = new TestClock(); + var collapseRun = collapseAnimation.RunAsync(border, clock2, cts2.Token); + + clock2.Step(TimeSpan.Zero); + clock2.Step(TimeSpan.FromSeconds(0.3)); + + Assert.True(collapseRun.IsCompleted); + Assert.False(border.IsVisible); + } + + [Fact] + public void Auto_Pauses_On_Invisible_When_Started_From_Style() + { + // When started via Apply (the style path), Auto resolves to OnlyIfVisible. + // The animation should pause when the control becomes invisible. + var keyframe1 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 100d) }, Cue = new Cue(0d) + }; + var keyframe2 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 200d) }, Cue = new Cue(1d) + }; + + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(3), + IterationCount = new IterationCount(1), + Children = { keyframe1, keyframe2 } + }; + + var border = new Border() { Height = 100d, Width = 50d }; + + var clock = new TestClock(); + var completed = false; + + // Apply (not RunAsync), this is the style-applied path. + var disposable = animation.Apply(border, clock, Observable.Return(true), () => completed = true); + + clock.Step(TimeSpan.Zero); + Assert.Equal(100d, border.Width); + + // Hide the control, animation should pause under Auto. + border.IsVisible = false; + + clock.Step(TimeSpan.FromSeconds(1.5)); + // Width should not have advanced while invisible. + Assert.Equal(100d, border.Width); + + // Show the control, animation resumes. + border.IsVisible = true; + + clock.Step(TimeSpan.FromSeconds(4.5)); + Assert.True(completed); + + disposable.Dispose(); + } + + [Fact] + public void Auto_Does_Not_Pause_On_Invisible_When_Started_Manually() + { + // When started via RunAsync (manual), Auto resolves to Always. + // The animation should NOT pause when the control becomes invisible. + var keyframe1 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 100d) }, Cue = new Cue(0d) + }; + var keyframe2 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 200d) }, Cue = new Cue(1d) + }; + + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(3), + IterationCount = new IterationCount(1), + Easing = new LinearEasing(), + FillMode = FillMode.Forward, + Children = { keyframe1, keyframe2 } + }; + + var border = new Border() { Height = 100d, Width = 50d }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + clock.Step(TimeSpan.Zero); + Assert.Equal(100d, border.Width); + + // Hide the control, animation should keep running under Auto + manual. + border.IsVisible = false; + + // Width should advance while invisible (not paused). + clock.Step(TimeSpan.FromSeconds(1.5)); + Assert.Equal(150d, border.Width); + Assert.False(animationRun.IsCompleted); + + clock.Step(TimeSpan.FromSeconds(3)); + Assert.True(animationRun.IsCompleted); + Assert.Equal(200d, border.Width); + } + private sealed class FakeAnimator : InterpolatingAnimator { public double LastProgress { get; set; } = double.NaN; From d30779c5a6625a2a5833a1acac31b8fba7a6f64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Mon, 23 Mar 2026 17:52:16 +0100 Subject: [PATCH 07/55] Rename PipsPager button theme APIs (#20954) * Rename PipsPager button theme * Updated PipsPager render tests * Updated tests * Updated Avalonia.nupkg.xml --- api/Avalonia.nupkg.xml | 96 ++++++++++++++++++ ...l => PipsPagerCustomButtonThemesPage.xaml} | 26 ++--- .../PipsPagerCustomButtonThemesPage.xaml.cs | 11 ++ .../PipsPagerCustomButtonsPage.xaml.cs | 11 -- .../Pages/PipsPagerPage.xaml.cs | 6 +- src/Avalonia.Controls/Page/NavigationPage.cs | 17 +++- src/Avalonia.Controls/PipsPager/PipsPager.cs | 28 ++--- .../Controls/PipsPager.xaml | 61 +++++++++-- .../Controls/PipsPager.xaml | 4 + .../DrawerPageTests.cs | 2 +- .../NavigationPageTests.cs | 28 ++++- .../PipsPagerTests.cs | 40 ++++++++ .../Controls/PipsPagerTests.cs | 45 ++++---- .../PipsPager/PipsPager_Default.expected.png | Bin 1498 -> 1087 bytes .../PipsPager_Preselected_Index.expected.png | Bin 1401 -> 1082 bytes 15 files changed, 291 insertions(+), 84 deletions(-) rename samples/ControlCatalog/Pages/PipsPager/{PipsPagerCustomButtonsPage.xaml => PipsPagerCustomButtonThemesPage.xaml} (83%) create mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonThemesPage.xaml.cs delete mode 100644 samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index b2a81dd55d..86fd0cdd75 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -5641,4 +5641,100 @@ baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll + + CP0002 + F:Avalonia.Controls.NavigationPage.IsBackButtonEffectivelyVisibleProperty + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.NavigationPage.get_IsBackButtonEffectivelyVisible + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + F:Avalonia.Controls.PipsPager.PreviousButtonStyleProperty + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + F:Avalonia.Controls.PipsPager.NextButtonStyleProperty + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.get_PreviousButtonStyle + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.set_PreviousButtonStyle(Avalonia.Styling.ControlTheme) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.get_NextButtonStyle + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.set_NextButtonStyle(Avalonia.Styling.ControlTheme) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + F:Avalonia.Controls.NavigationPage.IsBackButtonEffectivelyVisibleProperty + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.NavigationPage.get_IsBackButtonEffectivelyVisible + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + F:Avalonia.Controls.PipsPager.PreviousButtonStyleProperty + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + F:Avalonia.Controls.PipsPager.NextButtonStyleProperty + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.get_PreviousButtonStyle + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.set_PreviousButtonStyle(Avalonia.Styling.ControlTheme) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.get_NextButtonStyle + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.set_NextButtonStyle(Avalonia.Styling.ControlTheme) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonThemesPage.xaml similarity index 83% rename from samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml rename to samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonThemesPage.xaml index 8b9856424d..3e32f253f5 100644 --- a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonThemesPage.xaml @@ -1,17 +1,17 @@ + x:Class="ControlCatalog.Pages.PipsPagerCustomButtonThemesPage"> - + Text="Replace the default chevron navigation buttons with custom button themes using PreviousButtonTheme and NextButtonTheme." /> - - + + @@ -27,7 +27,7 @@ - + @@ -41,7 +41,7 @@ - + @@ -56,12 +56,12 @@ - - - - - - + + + + + + diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonThemesPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonThemesPage.xaml.cs new file mode 100644 index 0000000000..26682b9d61 --- /dev/null +++ b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonThemesPage.xaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace ControlCatalog.Pages; + +public partial class PipsPagerCustomButtonThemesPage : UserControl +{ + public PipsPagerCustomButtonThemesPage() + { + InitializeComponent(); + } +} diff --git a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs deleted file mode 100644 index 4fc74995bc..0000000000 --- a/samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Avalonia.Controls; - -namespace ControlCatalog.Pages; - -public partial class PipsPagerCustomButtonsPage : UserControl -{ - public PipsPagerCustomButtonsPage() - { - InitializeComponent(); - } -} diff --git a/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs b/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs index 33cc6d0fdf..0559a265e4 100644 --- a/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs +++ b/samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs @@ -25,9 +25,9 @@ namespace ControlCatalog.Pages ("Appearance", "Custom Colors", "Override pip indicator colors using resource keys for normal, selected, and hover states.", () => new PipsPagerCustomColorsPage()), - ("Appearance", "Custom Buttons", - "Replace the default chevron navigation buttons with custom styled buttons.", - () => new PipsPagerCustomButtonsPage()), + ("Appearance", "Custom Button Themes", + "Replace the default chevron navigation buttons with custom button themes.", + () => new PipsPagerCustomButtonThemesPage()), ("Appearance", "Custom Templates", "Override pip item templates to create squares, pills, numbers, or any custom shape.", () => new PipsPagerCustomTemplatesPage()), diff --git a/src/Avalonia.Controls/Page/NavigationPage.cs b/src/Avalonia.Controls/Page/NavigationPage.cs index 8e38fbbdbc..e736535e75 100644 --- a/src/Avalonia.Controls/Page/NavigationPage.cs +++ b/src/Avalonia.Controls/Page/NavigationPage.cs @@ -63,13 +63,14 @@ namespace Avalonia.Controls private ContentPresenter? _modalPresenter; private ContentPresenter? _topCommandBarPresenter; private IDisposable? _hasNavigationBarSub; + private IDisposable? _hasBackButtonSub; private IDisposable? _isBackButtonEnabledSub; private IDisposable? _barLayoutBehaviorSub; private IDisposable? _barHeightSub; private IDisposable? _backButtonContentSub; private bool _isNavigating; private bool _canGoBack; - private bool? _isBackButtonEffectivelyVisible; + private bool _isBackButtonEffectivelyVisible; private bool _isNavBarEffectivelyVisible; private double _effectiveBarHeight; private bool _isBackButtonEffectivelyEnabled; @@ -110,8 +111,8 @@ namespace Avalonia.Controls /// /// Defines the property. /// - public static readonly DirectProperty IsBackButtonEffectivelyVisibleProperty = - AvaloniaProperty.RegisterDirect(nameof(IsBackButtonEffectivelyVisible), o => o.IsBackButtonEffectivelyVisible); + public static readonly DirectProperty IsBackButtonEffectivelyVisibleProperty = + AvaloniaProperty.RegisterDirect(nameof(IsBackButtonEffectivelyVisible), o => o.IsBackButtonEffectivelyVisible); /// /// Defines the property. @@ -330,7 +331,7 @@ namespace Avalonia.Controls /// /// Gets the effective back-button visibility. /// - public bool? IsBackButtonEffectivelyVisible + public bool IsBackButtonEffectivelyVisible { get => _isBackButtonEffectivelyVisible; private set => SetAndRaise(IsBackButtonEffectivelyVisibleProperty, ref _isBackButtonEffectivelyVisible, value); @@ -730,6 +731,8 @@ namespace Avalonia.Controls _hasNavigationBarSub?.Dispose(); _hasNavigationBarSub = null; + _hasBackButtonSub?.Dispose(); + _hasBackButtonSub = null; _isBackButtonEnabledSub?.Dispose(); _isBackButtonEnabledSub = null; _barLayoutBehaviorSub?.Dispose(); @@ -1840,6 +1843,9 @@ namespace Avalonia.Controls _hasNavigationBarSub?.Dispose(); _hasNavigationBarSub = null; + _hasBackButtonSub?.Dispose(); + _hasBackButtonSub = null; + _isBackButtonEnabledSub?.Dispose(); _isBackButtonEnabledSub = null; @@ -1857,6 +1863,9 @@ namespace Avalonia.Controls _hasNavigationBarSub = page.GetObservable(HasNavigationBarProperty) .Subscribe(new AnonymousObserver(_ => UpdateIsNavBarEffectivelyVisible())); + _hasBackButtonSub = page.GetObservable(HasBackButtonProperty) + .Subscribe(new AnonymousObserver(_ => UpdateIsBackButtonEffectivelyVisible())); + _isBackButtonEnabledSub = page.GetObservable(IsBackButtonEnabledProperty) .Subscribe(new AnonymousObserver(_ => UpdateIsBackButtonEffectivelyEnabled())); diff --git a/src/Avalonia.Controls/PipsPager/PipsPager.cs b/src/Avalonia.Controls/PipsPager/PipsPager.cs index b976df4826..a83c4ade0d 100644 --- a/src/Avalonia.Controls/PipsPager/PipsPager.cs +++ b/src/Avalonia.Controls/PipsPager/PipsPager.cs @@ -87,16 +87,16 @@ namespace Avalonia.Controls x => x.TemplateSettings); /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty PreviousButtonStyleProperty = - AvaloniaProperty.Register(nameof(PreviousButtonStyle)); + public static readonly StyledProperty PreviousButtonThemeProperty = + AvaloniaProperty.Register(nameof(PreviousButtonTheme)); /// - /// Defines the property. + /// Defines the property. /// - public static readonly StyledProperty NextButtonStyleProperty = - AvaloniaProperty.Register(nameof(NextButtonStyle)); + public static readonly StyledProperty NextButtonThemeProperty = + AvaloniaProperty.Register(nameof(NextButtonTheme)); /// /// Defines the event. @@ -195,21 +195,21 @@ namespace Avalonia.Controls } /// - /// Gets or sets the style for the previous button. + /// Gets or sets the theme for the previous button. /// - public ControlTheme? PreviousButtonStyle + public ControlTheme? PreviousButtonTheme { - get => GetValue(PreviousButtonStyleProperty); - set => SetValue(PreviousButtonStyleProperty, value); + get => GetValue(PreviousButtonThemeProperty); + set => SetValue(PreviousButtonThemeProperty, value); } /// - /// Gets or sets the style for the next button. + /// Gets or sets the theme for the next button. /// - public ControlTheme? NextButtonStyle + public ControlTheme? NextButtonTheme { - get => GetValue(NextButtonStyleProperty); - set => SetValue(NextButtonStyleProperty, value); + get => GetValue(NextButtonThemeProperty); + set => SetValue(NextButtonThemeProperty, value); } /// diff --git a/src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml b/src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml index 6d20216a48..a331b63977 100644 --- a/src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml @@ -13,12 +13,12 @@ M 2.29,8.12 L 7.00,3.41 C 7.27,3.14 7.71,3.14 7.98,3.41 L 12.69,8.12 C 13.14,8.57 12.82,9.33 12.19,9.33 L 2.79,9.33 C 2.16,9.33 1.84,8.57 2.29,8.12 Z M 2.29,3.88 L 7.00,8.59 C 7.27,8.86 7.71,8.86 7.98,8.59 L 12.69,3.88 C 13.14,3.43 12.82,2.67 12.19,2.67 L 2.79,2.67 C 2.16,2.67 1.84,3.43 2.29,3.88 Z - + - + - - + + @@ -41,16 +41,20 @@ @@ -73,6 +77,21 @@ + + + + + + + + + + + + + @@ -122,8 +161,8 @@ - - + + -public partial class DispatcherTimer +public class DispatcherTimer { internal static int ActiveTimersCount { get; private set; } /// - /// Creates a timer that uses theUI thread's Dispatcher2 to - /// process the timer event at background priority. + /// Creates a timer that uses to + /// process the timer event at background priority. /// - public DispatcherTimer() : this(DispatcherPriority.Background) + public DispatcherTimer() + : this(TimeSpan.Zero, DispatcherPriority.Background, Dispatcher.CurrentDispatcher) { } /// - /// Creates a timer that uses the UI thread's Dispatcher2 to - /// process the timer event at the specified priority. + /// Creates a timer that uses to + /// process the timer event at the specified priority. /// - /// - /// The priority to process the timer at. - /// - public DispatcherTimer(DispatcherPriority priority) : this(Threading.Dispatcher.UIThread, priority, - TimeSpan.FromMilliseconds(0)) + /// The priority to process the timer at. + public DispatcherTimer(DispatcherPriority priority) + : this(TimeSpan.Zero, priority, Dispatcher.CurrentDispatcher) { } /// - /// Creates a timer that uses the specified Dispatcher2 to - /// process the timer event at the specified priority. + /// Creates a timer that uses the specified to + /// process the timer event at the specified priority. /// - /// - /// The priority to process the timer at. - /// - /// - /// The dispatcher to use to process the timer. - /// - internal DispatcherTimer(DispatcherPriority priority, Dispatcher dispatcher) : this(dispatcher, priority, - TimeSpan.FromMilliseconds(0)) + /// The priority to process the timer at. + /// The dispatcher to use to process the timer. + public DispatcherTimer(DispatcherPriority priority, Dispatcher dispatcher) + : this(TimeSpan.Zero, priority, dispatcher) { } /// - /// Creates a timer that uses the UI thread's Dispatcher2 to - /// process the timer event at the specified priority after the specified timeout. + /// Creates a timer that uses the specified to + /// process the timer event at the specified priority after the specified timeout. /// - /// - /// The interval to tick the timer after. - /// - /// - /// The priority to process the timer at. - /// - /// - /// The callback to call when the timer ticks. - /// - public DispatcherTimer(TimeSpan interval, DispatcherPriority priority, EventHandler callback) - : this(Threading.Dispatcher.UIThread, priority, interval) + /// The interval to tick the timer after. + /// The priority to process the timer at. + /// The dispatcher to use to process the timer. + public DispatcherTimer(TimeSpan interval, DispatcherPriority priority, Dispatcher dispatcher) { - if (callback == null) + ArgumentNullException.ThrowIfNull(dispatcher); + + DispatcherPriority.Validate(priority, "priority"); + if (priority == DispatcherPriority.Inactive) + { + throw new ArgumentException("Specified priority is not valid.", nameof(priority)); + } + + var ms = interval.TotalMilliseconds; + if (ms < 0) { - throw new ArgumentNullException(nameof(callback)); + throw new ArgumentOutOfRangeException(nameof(interval), + "TimeSpan period must be greater than or equal to zero."); } + if (ms > int.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(interval), + "TimeSpan period must be less than or equal to Int32.MaxValue."); + } + + _dispatcher = dispatcher; + _priority = priority; + _interval = interval; + } + + /// + /// Creates a timer that uses to + /// process the timer event at the specified priority after the specified timeout and with + /// the specified handler. + /// + /// The interval to tick the timer after. + /// The priority to process the timer at. + /// The callback to call when the timer ticks. + /// This constructor immediately starts the timer. + public DispatcherTimer(TimeSpan interval, DispatcherPriority priority, EventHandler callback) + : this(interval, priority, Dispatcher.CurrentDispatcher, callback) + { + } + + /// + /// Creates a timer that uses the specified to + /// process the timer event at the specified priority after the specified timeout and with + /// the specified handler. + /// + /// The interval to tick the timer after. + /// The priority to process the timer at. + /// The dispatcher to use to process the timer. + /// The callback to call when the timer ticks. + /// This constructor immediately starts the timer. + public DispatcherTimer(TimeSpan interval, DispatcherPriority priority, Dispatcher dispatcher, EventHandler callback) + : this(interval, priority, dispatcher) + { + ArgumentNullException.ThrowIfNull(callback); Tick += callback; Start(); @@ -252,33 +289,6 @@ public partial class DispatcherTimer /// public object? Tag { get; set; } - - internal DispatcherTimer(Dispatcher dispatcher, DispatcherPriority priority, TimeSpan interval) - { - if (dispatcher == null) - { - throw new ArgumentNullException(nameof(dispatcher)); - } - - DispatcherPriority.Validate(priority, "priority"); - if (priority == DispatcherPriority.Inactive) - { - throw new ArgumentException("Specified priority is not valid.", nameof(priority)); - } - - if (interval.TotalMilliseconds < 0) - throw new ArgumentOutOfRangeException(nameof(interval), "TimeSpan period must be greater than or equal to zero."); - - if (interval.TotalMilliseconds > Int32.MaxValue) - throw new ArgumentOutOfRangeException(nameof(interval), - "TimeSpan period must be less than or equal to Int32.MaxValue."); - - - _dispatcher = dispatcher; - _priority = priority; - _interval = interval; - } - private void Restart() { lock (_instanceLock) From cdc722264659ef5d43d6a84d780e0a14351a49ed Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Tue, 24 Mar 2026 21:43:00 +0500 Subject: [PATCH 11/55] Make Window.WindowState a direct property with (on some platforms) reliable values (#20973) * Make Window.WindowState a direct property with (on some platforms) reliable values * Use reported window state from the callback * compile * Actually use the cached value in WindowState getter * api diff * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Tests for our erratic WindowState behavior. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/Avalonia.nupkg.xml | 24 ++++ src/Avalonia.Controls/Platform/IWindowImpl.cs | 8 ++ src/Avalonia.Controls/Window.cs | 55 ++++++-- .../Remote/PreviewerWindowImpl.cs | 1 + src/Avalonia.DesignerSupport/Remote/Stubs.cs | 1 + src/Avalonia.Native/WindowImpl.cs | 1 + src/Avalonia.X11/X11Window.cs | 47 +++---- .../Avalonia.Headless/HeadlessWindowImpl.cs | 1 + src/Windows/Avalonia.Win32/WindowImpl.cs | 1 + .../WindowTests.cs | 122 ++++++++++++++++++ 10 files changed, 230 insertions(+), 31 deletions(-) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 86fd0cdd75..0e767b5ef3 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1939,6 +1939,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + F:Avalonia.Controls.Window.WindowStateProperty + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 M:Avalonia.AppBuilder.get_LifetimeOverride @@ -3601,6 +3607,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + F:Avalonia.Controls.Window.WindowStateProperty + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 M:Avalonia.AppBuilder.get_LifetimeOverride @@ -4561,6 +4573,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0006 + P:Avalonia.Platform.IWindowImpl.WindowStateGetterIsUsable + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0006 M:Avalonia.OpenGL.IGlPlatformSurfaceRenderTargetFactory.CanRenderToSurface(Avalonia.OpenGL.IGlContext,Avalonia.Platform.Surfaces.IPlatformRenderSurface) @@ -4915,6 +4933,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0006 + P:Avalonia.Platform.IWindowImpl.WindowStateGetterIsUsable + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0006 M:Avalonia.OpenGL.IGlExternalSemaphore.SignalTimelineSemaphore(Avalonia.OpenGL.IGlExternalImageTexture,System.UInt64) diff --git a/src/Avalonia.Controls/Platform/IWindowImpl.cs b/src/Avalonia.Controls/Platform/IWindowImpl.cs index 7a55a0386b..42be775f0d 100644 --- a/src/Avalonia.Controls/Platform/IWindowImpl.cs +++ b/src/Avalonia.Controls/Platform/IWindowImpl.cs @@ -16,6 +16,14 @@ namespace Avalonia.Platform /// Gets or sets the minimized/maximized state of the window. /// WindowState WindowState { get; set; } + + /// + /// Indicates if platform implementation has a working getter for that produces + /// consistent results with WindowStateChanged callback. + /// If false, Window will not call the getter and will only use the setter and + /// callback to track window state. + /// + bool WindowStateGetterIsUsable { get; } /// /// Gets or sets a method called when the minimized/maximized state of the window changes. diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index e7a4ce953e..80349634b7 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -157,10 +158,12 @@ namespace Avalonia.Controls AvaloniaProperty.Register(nameof(ClosingBehavior)); /// - /// Represents the current window state (normal, minimized, maximized) + /// Represents the currently effective window state (normal, minimized, maximized) /// - public static readonly StyledProperty WindowStateProperty = - AvaloniaProperty.Register(nameof(WindowState)); + public static readonly DirectProperty WindowStateProperty = + AvaloniaProperty.RegisterDirect( + nameof(WindowState), o => o.WindowState, + (o, v) => o.WindowState = v); /// /// Defines the property. @@ -254,8 +257,7 @@ namespace Avalonia.Controls CreatePlatformImplBinding(CanMinimizeProperty, canMinimize => PlatformImpl!.SetCanMinimize(canMinimize)); CreatePlatformImplBinding(CanMaximizeProperty, canMaximize => PlatformImpl!.SetCanMaximize(canMaximize)); CreatePlatformImplBinding(ShowInTaskbarProperty, show => PlatformImpl!.ShowTaskbarIcon(show)); - - CreatePlatformImplBinding(WindowStateProperty, state => PlatformImpl!.WindowState = state); + CreatePlatformImplBinding(ExtendClientAreaToDecorationsHintProperty, hint => PlatformImpl!.SetExtendClientAreaToDecorationsHint(hint)); CreatePlatformImplBinding(ExtendClientAreaTitleBarHeightHintProperty, height => PlatformImpl!.SetExtendClientAreaTitleBarHeightHint(height)); @@ -402,13 +404,45 @@ namespace Avalonia.Controls set => SetValue(ClosingBehaviorProperty, value); } + private WindowState _lastWindowState; /// - /// Gets or sets the minimized/maximized state of the window. + /// Represents the currently effective window state (normal, minimized, maximized) /// public WindowState WindowState { - get => GetValue(WindowStateProperty); - set => SetValue(WindowStateProperty, value); + get => PlatformImpl?.WindowStateGetterIsUsable == true ? + PlatformImpl.WindowState : + _lastWindowState; + set + { + if (PlatformImpl != null) + { + if (PlatformImpl.WindowStateGetterIsUsable) + { + // Attempt to set the window state to desired value, if it succeeds the platform will + // trigger WindowStateChanged callback which will trigger SetAndRaise for the WindowState property + PlatformImpl.WindowState = value; + + // If the request was refused - trigger a synthetic property change notification + // for data bindings and user state to fix itself. + if (PlatformImpl.WindowState != value) + { + // Since it's a force notify, we aren't checking for the old value and sometimes + // trigger notification with oldValue = newValue. + var oldValue = _lastWindowState; + _lastWindowState = PlatformImpl.WindowState; + RaisePropertyChanged(WindowStateProperty, oldValue, _lastWindowState); + } + } + else + { + // Legacy behavior - update the property and hope for the best that the platform + // will update it back to match the actual window state + SetAndRaise(WindowStateProperty, ref _lastWindowState, value); + PlatformImpl.WindowState = _lastWindowState; + } + } + } } /// @@ -616,7 +650,10 @@ namespace Avalonia.Controls private void HandleWindowStateChanged(WindowState state) { - WindowState = state; + // Check if platform impl doesn't lie about get_WindowState being usable + Debug.Assert(PlatformImpl is not { WindowStateGetterIsUsable: true } || PlatformImpl.WindowState == state); + + SetAndRaise(WindowStateProperty, ref _lastWindowState, state); if (state == WindowState.Minimized) { diff --git a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs index e714b62511..35d0a0cb80 100644 --- a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs +++ b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs @@ -44,6 +44,7 @@ namespace Avalonia.DesignerSupport.Remote public Action Activated { get; set; } public Func Closing { get; set; } public WindowState WindowState { get; set; } + public bool WindowStateGetterIsUsable => false; public Action WindowStateChanged { get; set; } public Size MaxAutoSizeHint { get; } = new Size(4096, 4096); diff --git a/src/Avalonia.DesignerSupport/Remote/Stubs.cs b/src/Avalonia.DesignerSupport/Remote/Stubs.cs index d9c8e333cb..8149e5ad71 100644 --- a/src/Avalonia.DesignerSupport/Remote/Stubs.cs +++ b/src/Avalonia.DesignerSupport/Remote/Stubs.cs @@ -44,6 +44,7 @@ namespace Avalonia.DesignerSupport.Remote public PixelPoint Position { get; set; } public Action? PositionChanged { get; set; } public WindowState WindowState { get; set; } + public bool WindowStateGetterIsUsable => false; public Action? WindowStateChanged { get; set; } public Action? TransparencyLevelChanged { get; set; } diff --git a/src/Avalonia.Native/WindowImpl.cs b/src/Avalonia.Native/WindowImpl.cs index f23b6cbc62..76f47150db 100644 --- a/src/Avalonia.Native/WindowImpl.cs +++ b/src/Avalonia.Native/WindowImpl.cs @@ -114,6 +114,7 @@ namespace Avalonia.Native set => _native.SetWindowState((AvnWindowState)value); } + public bool WindowStateGetterIsUsable => false; public Action? WindowStateChanged { get; set; } public Action? ExtendClientAreaToDecorationsChanged { get; set; } diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index bf20600a18..0cfa226163 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -228,6 +228,7 @@ namespace Avalonia.X11 surfaces.Add(new SurfacePlatformHandle(this)); Surfaces = surfaces.ToArray(); + UpdateEffectiveSystemDecorations(); UpdateMotifHints(); UpdateSizeHints(null); @@ -744,6 +745,8 @@ namespace Avalonia.X11 return false; } + public bool WindowStateGetterIsUsable => true; + private WindowState _lastWindowState; public WindowState WindowState { @@ -752,7 +755,6 @@ namespace Avalonia.X11 { if(_lastWindowState == value) return; - _lastWindowState = value; if (value == WindowState.Minimized) { XIconifyWindow(_x11.Display, _handle, _x11.DefaultScreen); @@ -780,7 +782,6 @@ namespace Avalonia.X11 SendNetWMMessage(_x11.Atoms._NET_ACTIVE_WINDOW, (IntPtr)1, _x11.LastActivityTimestamp, IntPtr.Zero); } - WindowStateChanged?.Invoke(value); } } @@ -795,42 +796,44 @@ namespace Avalonia.X11 if (property == _x11.Atoms._NET_WM_STATE) { - WindowState state = WindowState.Normal; var atoms = hasValue ? XGetWindowPropertyAsIntPtrArray(_x11.Display, _handle, _x11.Atoms._NET_WM_STATE, (IntPtr)Atom.XA_ATOM) ?? [] : []; int maximized = 0; + bool hasMinimized = false, hasFullscreen = false; foreach (var atom in atoms) { - if (atom == _x11.Atoms._NET_WM_STATE_HIDDEN) - { - state = WindowState.Minimized; - break; - } - - if(atom == _x11.Atoms._NET_WM_STATE_FULLSCREEN) - { - state = WindowState.FullScreen; - break; - } + if (atom == _x11.Atoms._NET_WM_STATE_HIDDEN) + hasMinimized = true; if (atom == _x11.Atoms._NET_WM_STATE_MAXIMIZED_HORZ || - atom == _x11.Atoms._NET_WM_STATE_MAXIMIZED_VERT) - { + atom == _x11.Atoms._NET_WM_STATE_MAXIMIZED_VERT) maximized++; - if (maximized == 2) - { - state = WindowState.Maximized; - break; - } - } + + if(atom == _x11.Atoms._NET_WM_STATE_FULLSCREEN) + hasFullscreen = true; } + + var state = hasMinimized ? WindowState.Minimized + : hasFullscreen ? WindowState.FullScreen + : maximized == 2 ? WindowState.Maximized + : WindowState.Normal; + if (_lastWindowState != state) { _lastWindowState = state; WindowStateChanged?.Invoke(state); + + XGetGeometry(_x11.Display, _handle, out var _, out var _, out var _, out var width, out var height, + out var _, out var _); + var newSize = new PixelSize(width, height); + if (newSize != _realSize) + { + _realSize = newSize; + Resized?.Invoke(ClientSize, WindowResizeReason.Unspecified); + } } _activationTracker?.OnNetWmStateChanged(atoms); diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs index 999a20644f..f2a7bf3575 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs @@ -141,6 +141,7 @@ namespace Avalonia.Headless } public WindowState WindowState { get; set; } + public bool WindowStateGetterIsUsable => false; public Action? WindowStateChanged { get; set; } public void SetTitle(string? title) { diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 92564d296d..0882516f57 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -208,6 +208,7 @@ namespace Avalonia.Win32 public Action? PositionChanged { get; set; } + public bool WindowStateGetterIsUsable => false; public Action? WindowStateChanged { get; set; } public Action? LostFocus { get; set; } diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index 59a84462ef..5347acbc33 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reactive.Linq; using System.Threading.Tasks; using Avalonia.Media; using Avalonia.Platform; @@ -1208,6 +1209,127 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void WindowState_UsableGetter_Setter_Updates_Only_After_Platform_Callback() + { + var windowImpl = MockWindowingPlatform.CreateWindowMock(); + + // Simulate a platform where the getter is usable and the setter is accepted + var platformState = WindowState.Normal; + windowImpl.Setup(x => x.WindowStateGetterIsUsable).Returns(true); + windowImpl.Setup(x => x.WindowState).Returns(() => platformState); + windowImpl.SetupSet(x => x.WindowState = It.IsAny()) + .Callback(v => + { + platformState = v; + // Platform accepts the state and fires the callback + windowImpl.Object.WindowStateChanged?.Invoke(v); + }); + + var windowingPlatform = new MockWindowingPlatform(() => windowImpl.Object); + using (UnitTestApplication.Start(new TestServices(windowingPlatform: windowingPlatform))) + { + var target = new Window(); + target.Show(); + + var raised = new List(); + target.GetObservable(Window.WindowStateProperty).Skip(1).Subscribe(s => raised.Add(s)); + + // Set to Maximized - platform accepts and fires callback + target.WindowState = WindowState.Maximized; + Assert.Equal(WindowState.Maximized, target.WindowState); + Assert.Contains(WindowState.Maximized, raised); + + // Set to FullScreen - platform accepts and fires callback + raised.Clear(); + target.WindowState = WindowState.FullScreen; + Assert.Equal(WindowState.FullScreen, target.WindowState); + Assert.Contains(WindowState.FullScreen, raised); + } + } + + [Fact] + public void WindowState_UsableGetter_Setter_Raises_Synthetic_Notification_When_Platform_Refuses() + { + var windowImpl = MockWindowingPlatform.CreateWindowMock(); + + // Simulate a platform where the getter is usable but refuses state change requests. + // Start in Maximized state, then refuse a request to go Normal. + var platformState = WindowState.Normal; + windowImpl.Setup(x => x.WindowStateGetterIsUsable).Returns(true); + windowImpl.Setup(x => x.WindowState).Returns(() => platformState); + windowImpl.SetupSet(x => x.WindowState = It.IsAny()) + .Callback(v => + { + // Platform accepts Maximized but refuses everything else + if (v == WindowState.Maximized) + { + platformState = v; + windowImpl.Object.WindowStateChanged?.Invoke(v); + } + // else: platform refuses, does not change state + }); + + var windowingPlatform = new MockWindowingPlatform(() => windowImpl.Object); + using (UnitTestApplication.Start(new TestServices(windowingPlatform: windowingPlatform))) + { + var target = new Window(); + target.Show(); + + // First, go to Maximized (accepted by platform) + target.WindowState = WindowState.Maximized; + Assert.Equal(WindowState.Maximized, target.WindowState); + + var raised = new List(); + target.PropertyChanged += (_, e) => + { + if (e.Property == Window.WindowStateProperty) + raised.Add(e); + }; + + // Now try to go to FullScreen - platform refuses, stays Maximized + target.WindowState = WindowState.FullScreen; + + // The getter should still return Maximized because the platform refused + Assert.Equal(WindowState.Maximized, target.WindowState); + + // A synthetic notification should have been raised so data bindings can recover + Assert.NotEmpty(raised); + Assert.Equal(WindowState.Maximized, raised[^1].GetNewValue()); + } + } + + [Fact] + public void WindowState_NonUsableGetter_Setter_Updates_Immediately() + { + var windowImpl = MockWindowingPlatform.CreateWindowMock(); + + // Legacy behavior: WindowStateGetterIsUsable = false + windowImpl.Setup(x => x.WindowStateGetterIsUsable).Returns(false); + + var windowingPlatform = new MockWindowingPlatform(() => windowImpl.Object); + using (UnitTestApplication.Start(new TestServices(windowingPlatform: windowingPlatform))) + { + var target = new Window(); + target.Show(); + + var raised = new List(); + target.GetObservable(Window.WindowStateProperty).Skip(1).Subscribe(s => raised.Add(s)); + + // Set to Maximized - should update immediately regardless of platform behavior + target.WindowState = WindowState.Maximized; + + Assert.Equal(WindowState.Maximized, target.WindowState); + Assert.Contains(WindowState.Maximized, raised); + + // Verify the setter was forwarded to the platform impl + windowImpl.VerifySet(x => x.WindowState = WindowState.Maximized); + + // Platform getter should never be called in legacy mode + windowImpl.VerifyGet(x => x.WindowState, Times.Never()); + } + } + private class TopmostWindow : Window { static TopmostWindow() From 9e4e454ad0a3b98326bfda6c438957217b734715 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Wed, 25 Mar 2026 08:58:45 +0000 Subject: [PATCH 12/55] add StyleKeyOverride for ContentPage (#20977) --- src/Avalonia.Controls/Page/ContentPage.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Avalonia.Controls/Page/ContentPage.cs b/src/Avalonia.Controls/Page/ContentPage.cs index b7523b4688..41fec8d1fc 100644 --- a/src/Avalonia.Controls/Page/ContentPage.cs +++ b/src/Avalonia.Controls/Page/ContentPage.cs @@ -151,6 +151,8 @@ namespace Avalonia.Controls protected override AutomationPeer OnCreateAutomationPeer() => new ContentPageAutomationPeer(this); + protected override Type StyleKeyOverride => typeof(ContentPage); + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); From 3a316845e843a34b46a4449390716a06530d4ad8 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Wed, 25 Mar 2026 10:16:13 +0000 Subject: [PATCH 13/55] add page header template (#20982) --- src/Avalonia.Controls/Page/Page.cs | 15 +++++++++++++++ .../Controls/NavigationPage.xaml | 1 + .../Controls/NavigationPage.xaml | 1 + 3 files changed, 17 insertions(+) diff --git a/src/Avalonia.Controls/Page/Page.cs b/src/Avalonia.Controls/Page/Page.cs index a12ce76ddf..4dcb84312d 100644 --- a/src/Avalonia.Controls/Page/Page.cs +++ b/src/Avalonia.Controls/Page/Page.cs @@ -26,6 +26,12 @@ namespace Avalonia.Controls public static readonly StyledProperty HeaderProperty = AvaloniaProperty.Register(nameof(Header)); + /// + /// Defines the property. + /// + public static readonly StyledProperty HeaderTemplateProperty = + AvaloniaProperty.Register(nameof(HeaderTemplate)); + /// /// Defines the property. /// @@ -92,6 +98,15 @@ namespace Avalonia.Controls set => SetValue(HeaderProperty, value); } + /// + /// Gets or sets the data template used to display the header. + /// + public IDataTemplate? HeaderTemplate + { + get => GetValue(HeaderTemplateProperty); + set => SetValue(HeaderTemplateProperty, value); + } + /// /// Gets or sets the icon displayed alongside the page header. /// diff --git a/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml b/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml index 8c2172a787..45eee95081 100644 --- a/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml @@ -140,6 +140,7 @@ FontSize="20" Foreground="{DynamicResource NavigationBarForeground}" Content="{Binding CurrentPage?.Header, RelativeSource={RelativeSource TemplatedParent}, FallbackValue={x:Null}}" + ContentTemplate="{Binding CurrentPage?.HeaderTemplate, RelativeSource={RelativeSource TemplatedParent}, FallbackValue={x:Null}}" VerticalAlignment="Center" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" diff --git a/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml b/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml index 66325a9ba7..88d5a9a2df 100644 --- a/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml +++ b/src/Avalonia.Themes.Simple/Controls/NavigationPage.xaml @@ -134,6 +134,7 @@ FontSize="18" Foreground="{DynamicResource NavigationBarForeground}" Content="{Binding CurrentPage?.Header, RelativeSource={RelativeSource TemplatedParent}, FallbackValue={x:Null}}" + ContentTemplate="{Binding CurrentPage?.HeaderTemplate, RelativeSource={RelativeSource TemplatedParent}, FallbackValue={x:Null}}" VerticalAlignment="Center" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" From d323efb668c6ece19fc8743c658401922929fcf5 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Wed, 25 Mar 2026 13:43:25 +0100 Subject: [PATCH 14/55] Cache RenderScaling in PresentationSource (#20972) * Cache RenderScaling in PresentationSource * Unsubscribe from PlatformImpl.ScalingChanged * Added tests for ValidateScaling --- src/Avalonia.Base/Layout/LayoutHelper.cs | 15 +++++++++++ .../PresentationSource.RenderRoot.cs | 10 ++++--- .../PresentationSource/PresentationSource.cs | 6 ++++- src/Avalonia.Controls/TopLevel.cs | 24 +++-------------- .../Layout/LayoutHelperTests.cs | 26 +++++++++++++++++++ 5 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/Avalonia.Base/Layout/LayoutHelper.cs b/src/Avalonia.Base/Layout/LayoutHelper.cs index fd81ffaa49..286a79ee8a 100644 --- a/src/Avalonia.Base/Layout/LayoutHelper.cs +++ b/src/Avalonia.Base/Layout/LayoutHelper.cs @@ -265,5 +265,20 @@ namespace Avalonia.Layout // should be. return Math.Round(value, 8, MidpointRounding.ToZero); } + + internal static double ValidateScaling(double scaling) + { + if (MathUtilities.IsNegativeOrNonFinite(scaling) || MathUtilities.IsZero(scaling)) + throw new InvalidOperationException($"Invalid render scaling value {scaling}"); + + if (MathUtilities.IsOne(scaling)) + { + // Ensure we've got exactly 1.0 and not an approximation, + // so we don't have to use MathUtilities.IsOne in various layout hot paths. + return 1.0; + } + + return scaling; + } } } diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs index d017cb3f5c..7c129742b8 100644 --- a/src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs +++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs @@ -1,5 +1,5 @@ using System; -using Avalonia.Input; +using Avalonia.Layout; using Avalonia.Rendering; using Avalonia.Rendering.Composition; @@ -15,7 +15,8 @@ internal partial class PresentationSource //TODO: Can we PLEASE get rid of this abomination in tests and use actual hit-testing engine instead? public IHitTester? HitTesterOverride { get; set; } - public double RenderScaling => PlatformImpl?.RenderScaling ?? 1; + public double RenderScaling { get; private set; } = 1.0; + public Size ClientSize => _clientSizeProvider(); public void SceneInvalidated(object? sender, SceneInvalidatedEventArgs sceneInvalidatedEventArgs) @@ -26,4 +27,7 @@ internal partial class PresentationSource public PixelPoint PointToScreen(Point point) => PlatformImpl?.PointToScreen(point) ?? default; public Point PointToClient(PixelPoint point) => PlatformImpl?.PointToClient(point) ?? default; -} \ No newline at end of file + + private void HandleScalingChanged(double scaling) + => RenderScaling = LayoutHelper.ValidateScaling(scaling); +} diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs index 9917f82c93..aad1bc1003 100644 --- a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs +++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs @@ -29,12 +29,15 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi PlatformImpl = platformImpl; - + _inputManager = TryGetService(dependencyResolver); _handleInputCore = HandleInputCore; PlatformImpl.SetInputRoot(this); PlatformImpl.Input = HandleInput; + + RenderScaling = LayoutHelper.ValidateScaling(platformImpl.RenderScaling); + PlatformImpl.ScalingChanged += HandleScalingChanged; _pointerOverPreProcessor = new PointerOverPreProcessor(this); _pointerOverPreProcessorSubscription = _inputManager?.PreProcess.Subscribe(_pointerOverPreProcessor); @@ -83,6 +86,7 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi // We need to wait for the renderer to complete any in-flight operations Renderer.Dispose(); + PlatformImpl?.ScalingChanged -= HandleScalingChanged; PlatformImpl = null; _pointerOverPreProcessor?.OnCompleted(); _pointerOverPreProcessorSubscription?.Dispose(); diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index ceb9590564..69d32d2b56 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -215,7 +215,7 @@ namespace Avalonia.Controls impl, dependencyResolver, () => ClientSize); _source.Renderer.SceneInvalidated += SceneInvalidated; - _scaling = ValidateScaling(impl.RenderScaling); + _scaling = LayoutHelper.ValidateScaling(impl.RenderScaling); _actualTransparencyLevel = PlatformImpl.TransparencyLevel; @@ -234,7 +234,7 @@ namespace Avalonia.Controls impl.Closed = HandleClosed; impl.Paint = HandlePaint; impl.Resized = HandleResized; - impl.ScalingChanged = HandleScalingChanged; + impl.ScalingChanged += HandleScalingChanged; impl.TransparencyLevelChanged = HandleTransparencyLevelChanged; CreatePlatformImplBinding(TransparencyLevelHintProperty, hint => PlatformImpl.SetTransparencyLevelHint(hint ?? Array.Empty())); @@ -709,7 +709,7 @@ namespace Avalonia.Controls /// The window scaling. private void HandleScalingChanged(double scaling) { - _scaling = ValidateScaling(scaling); + _scaling = LayoutHelper.ValidateScaling(scaling); LayoutHelper.InvalidateSelfAndChildrenMeasure(this); Dispatcher.UIThread.Send(_ => ScalingChanged?.Invoke(this, EventArgs.Empty)); @@ -833,23 +833,5 @@ namespace Avalonia.Controls { // Do nothing becuase TopLevel should't apply MirrorTransform on himself. } - - private double ValidateScaling(double scaling) - { - if (MathUtilities.IsNegativeOrNonFinite(scaling) || MathUtilities.IsZero(scaling)) - { - throw new InvalidOperationException( - $"Invalid {nameof(ITopLevelImpl.RenderScaling)} value {scaling} returned from {PlatformImpl?.GetType()}"); - } - - if (MathUtilities.IsOne(scaling)) - { - // Ensure we've got exactly 1.0 and not an approximation, - // so we don't have to use MathUtilities.IsOne in various layout hot paths. - return 1.0; - } - - return scaling; - } } } diff --git a/tests/Avalonia.Base.UnitTests/Layout/LayoutHelperTests.cs b/tests/Avalonia.Base.UnitTests/Layout/LayoutHelperTests.cs index 3df31939d1..af71e7206d 100644 --- a/tests/Avalonia.Base.UnitTests/Layout/LayoutHelperTests.cs +++ b/tests/Avalonia.Base.UnitTests/Layout/LayoutHelperTests.cs @@ -24,5 +24,31 @@ namespace Avalonia.Base.UnitTests.Layout var actualValue = LayoutHelper.RoundLayoutValue(value, dpiScale); Assert.Equal(expectedValue, actualValue); } + + [Fact] + public void ValidateScaling_Returns_Exact_One_For_Approximate_One() + { + var result = LayoutHelper.ValidateScaling(1.000000000000001); + Assert.Equal(1.0, result); + } + + [Fact] + public void ValidateScaling_Returns_Valid_Scaling_Value() + { + const double scaling = 1.5; + var result = LayoutHelper.ValidateScaling(scaling); + Assert.Equal(scaling, result); + } + + [Theory] + [InlineData(0.0)] + [InlineData(-1.5)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void ValidateScaling_Throws_For_Invalid_Values(double scaling) + { + Assert.Throws(() => LayoutHelper.ValidateScaling(scaling)); + } } } From bd041f225c8a3e40a98a09d35e04e14d663db2a1 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Wed, 25 Mar 2026 13:55:59 +0000 Subject: [PATCH 15/55] add style overrides for other page types (#20985) --- src/Avalonia.Controls/Page/CarouselPage.cs | 5 +++-- src/Avalonia.Controls/Page/DrawerPage.cs | 2 ++ src/Avalonia.Controls/Page/NavigationPage.cs | 8 ++++---- src/Avalonia.Controls/Page/TabbedPage.cs | 3 ++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Controls/Page/CarouselPage.cs b/src/Avalonia.Controls/Page/CarouselPage.cs index 1b640fd528..22bee65871 100644 --- a/src/Avalonia.Controls/Page/CarouselPage.cs +++ b/src/Avalonia.Controls/Page/CarouselPage.cs @@ -1,6 +1,6 @@ +using System; using System.Collections; using System.Collections.Generic; -using System.Linq; using Avalonia.Animation; using Avalonia.Automation; using Avalonia.Automation.Peers; @@ -11,7 +11,6 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; -using Avalonia.Media; using Avalonia.Threading; namespace Avalonia.Controls @@ -111,6 +110,8 @@ namespace Avalonia.Controls set => SetValue(IsKeyboardNavigationEnabledProperty, value); } + protected override Type StyleKeyOverride => typeof(CarouselPage); + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); diff --git a/src/Avalonia.Controls/Page/DrawerPage.cs b/src/Avalonia.Controls/Page/DrawerPage.cs index 954ec9c918..f8e039f8cd 100644 --- a/src/Avalonia.Controls/Page/DrawerPage.cs +++ b/src/Avalonia.Controls/Page/DrawerPage.cs @@ -535,6 +535,8 @@ namespace Avalonia.Controls set => SetValue(DisplayModeProperty, value); } + protected override Type StyleKeyOverride => typeof(DrawerPage); + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); diff --git a/src/Avalonia.Controls/Page/NavigationPage.cs b/src/Avalonia.Controls/Page/NavigationPage.cs index e736535e75..525851af89 100644 --- a/src/Avalonia.Controls/Page/NavigationPage.cs +++ b/src/Avalonia.Controls/Page/NavigationPage.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -7,14 +6,13 @@ using Avalonia.Animation; using Avalonia.Automation; using Avalonia.Automation.Peers; using Avalonia.Controls.Metadata; -using Avalonia.Logging; -using Avalonia.LogicalTree; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; -using Avalonia.Controls.Shapes; using Avalonia.Input; using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; +using Avalonia.Logging; +using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.Metadata; using Avalonia.Reactive; @@ -619,6 +617,8 @@ namespace Avalonia.Controls } } + protected override Type StyleKeyOverride => typeof(NavigationPage); + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { if (change.Property == PagesProperty && diff --git a/src/Avalonia.Controls/Page/TabbedPage.cs b/src/Avalonia.Controls/Page/TabbedPage.cs index 69815eb56a..89e926dc3c 100644 --- a/src/Avalonia.Controls/Page/TabbedPage.cs +++ b/src/Avalonia.Controls/Page/TabbedPage.cs @@ -9,7 +9,6 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Input.GestureRecognizers; -using Avalonia.LogicalTree; using Avalonia.Threading; namespace Avalonia.Controls @@ -150,6 +149,8 @@ namespace Avalonia.Controls set => SetValue(IndicatorTemplateProperty, value); } + protected override Type StyleKeyOverride => typeof(TabbedPage); + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); From 76d6ce8e3052f30ed03eef112a9cebcb12df1349 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Wed, 25 Mar 2026 17:45:36 +0100 Subject: [PATCH 16/55] X11: Handle ShowActivated=false (#20958) --- src/Avalonia.X11/X11WindowModes/DefaultWindowMode.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.X11/X11WindowModes/DefaultWindowMode.cs b/src/Avalonia.X11/X11WindowModes/DefaultWindowMode.cs index d155a7f2c1..37cdca910d 100644 --- a/src/Avalonia.X11/X11WindowModes/DefaultWindowMode.cs +++ b/src/Avalonia.X11/X11WindowModes/DefaultWindowMode.cs @@ -31,6 +31,14 @@ partial class X11Window public override void Show(bool activate, bool isDialog) { Window._wasMappedAtLeastOnce = true; + + if (!activate) + { + var time = IntPtr.Zero; + XChangeProperty(X11.Display, Handle, X11.Atoms._NET_WM_USER_TIME, X11.Atoms.CARDINAL, 32, + PropertyMode.Replace, ref time, 1); + } + XMapWindow(X11.Display, Handle); XFlush(X11.Display); base.Show(activate, isDialog); @@ -50,4 +58,4 @@ partial class X11Window (int)(point.X * Window.RenderScaling + (Window._position ?? default).X), (int)(point.Y * Window.RenderScaling + (Window._position ?? default).Y)); } -} \ No newline at end of file +} From 953e5800cef8f324c4c6af364397b9750f4200d8 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 25 Mar 2026 09:53:17 -0700 Subject: [PATCH 17/55] Design properties not being applied for user controls (#20986) * Split source and target controls in Should_Apply_Design_Mode_Properties_From_Control_To_Window test * Fix invalid binding being applied * Use indexer bindings, avoid bind to observable --- src/Avalonia.Controls/Design.cs | 6 +++--- tests/Avalonia.Controls.UnitTests/DesignTests.cs | 15 +++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Avalonia.Controls/Design.cs b/src/Avalonia.Controls/Design.cs index 1e7912d75f..1f7dd4d934 100644 --- a/src/Avalonia.Controls/Design.cs +++ b/src/Avalonia.Controls/Design.cs @@ -303,11 +303,11 @@ namespace Avalonia.Controls public static void ApplyDesignModeProperties(Control target, Control source) { if (source.IsSet(WidthProperty)) - target.Bind(Layoutable.WidthProperty, target.GetBindingObservable(WidthProperty)); + target.Bind(Layoutable.WidthProperty, source[!WidthProperty]); if (source.IsSet(HeightProperty)) - target.Bind(Layoutable.HeightProperty, target.GetBindingObservable(HeightProperty)); + target.Bind(Layoutable.HeightProperty, source[!HeightProperty]); if (source.IsSet(DataContextProperty)) - target.Bind(StyledElement.DataContextProperty, target.GetBindingObservable(DataContextProperty)); + target.Bind(StyledElement.DataContextProperty, source[!DataContextProperty]); if (source.IsSet(DesignStyleProperty)) target.Styles.Add(GetDesignStyle(source)); } diff --git a/tests/Avalonia.Controls.UnitTests/DesignTests.cs b/tests/Avalonia.Controls.UnitTests/DesignTests.cs index 6845e4d369..b874604e96 100644 --- a/tests/Avalonia.Controls.UnitTests/DesignTests.cs +++ b/tests/Avalonia.Controls.UnitTests/DesignTests.cs @@ -79,11 +79,14 @@ public class DesignTests : ScopedTestBase } [Fact] - public void Should_Apply_Design_Mode_Properties() + public void Should_Apply_Design_Mode_Properties_From_Control_To_Window() { using var _ = UnitTestApplication.Start(TestServices.StyledWindow); + // Use-case: User previews a control, which is wrapped by the window. + var window = new Window(); var control = new ContentControl(); + window.Content = control; Design.SetWidth(control, 200); Design.SetHeight(control, 150); @@ -94,12 +97,12 @@ public class DesignTests : ScopedTestBase Setters = { new Setter(TemplatedControl.BackgroundProperty, Brushes.Yellow) } }); - Design.ApplyDesignModeProperties(control, control); + Design.ApplyDesignModeProperties(window, control); - Assert.Equal(200, control.Width); - Assert.Equal(150, control.Height); - Assert.Equal("TestDataContext", control.DataContext); - Assert.Contains(control.Styles, + Assert.Equal(200, window.Width); + Assert.Equal(150, window.Height); + Assert.Equal("TestDataContext", window.DataContext); + Assert.Contains(window.Styles, s => ((Style)s).Setters.OfType().First().Property == TemplatedControl.BackgroundProperty); } From ffa6406b6462f075008da6db30b2965b99671a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Wed, 25 Mar 2026 18:13:00 +0100 Subject: [PATCH 18/55] Add more gesture recognizer sample pages to ControlCatalog (#20983) * Added Gestures Samples * More changes --- samples/ControlCatalog/Pages/GesturePage.cs | 215 ++---------------- samples/ControlCatalog/Pages/GesturePage.xaml | 173 +------------- .../Gestures/GesturePinchRotationPage.xaml | 59 +++++ .../Gestures/GesturePinchRotationPage.xaml.cs | 33 +++ .../Pages/Gestures/GesturePinchZoomPage.xaml | 51 +++++ .../Gestures/GesturePinchZoomPage.xaml.cs | 134 +++++++++++ .../Pages/Gestures/GesturePullPage.xaml | 113 +++++++++ .../Pages/Gestures/GesturePullPage.xaml.cs | 100 ++++++++ .../Pages/Gestures/GestureSwipePage.xaml | 123 ++++++++++ .../Pages/Gestures/GestureSwipePage.xaml.cs | 125 ++++++++++ 10 files changed, 765 insertions(+), 361 deletions(-) create mode 100644 samples/ControlCatalog/Pages/Gestures/GesturePinchRotationPage.xaml create mode 100644 samples/ControlCatalog/Pages/Gestures/GesturePinchRotationPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/Gestures/GesturePinchZoomPage.xaml create mode 100644 samples/ControlCatalog/Pages/Gestures/GesturePinchZoomPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/Gestures/GesturePullPage.xaml create mode 100644 samples/ControlCatalog/Pages/Gestures/GesturePullPage.xaml.cs create mode 100644 samples/ControlCatalog/Pages/Gestures/GestureSwipePage.xaml create mode 100644 samples/ControlCatalog/Pages/Gestures/GestureSwipePage.xaml.cs diff --git a/samples/ControlCatalog/Pages/GesturePage.cs b/samples/ControlCatalog/Pages/GesturePage.cs index 2906091daa..d8b89e0ecb 100644 --- a/samples/ControlCatalog/Pages/GesturePage.cs +++ b/samples/ControlCatalog/Pages/GesturePage.cs @@ -1,214 +1,39 @@ using System; -using Avalonia; using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.LogicalTree; -using Avalonia.Rendering.Composition; -using Avalonia.Utilities; +using Avalonia.Interactivity; namespace ControlCatalog.Pages { public partial class GesturePage : UserControl { - private bool _isInit; - private double _currentScale; - - public GesturePage() - { - InitializeComponent(); - } - - protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + private static readonly (string Group, string Title, string Description, Func Factory)[] Demos = { - base.OnAttachedToVisualTree(e); - - if (_isInit) - { - return; - } - - _isInit = true; - - SetPullHandlers(TopPullZone, false); - SetPullHandlers(BottomPullZone, true); - SetPullHandlers(RightPullZone, true); - SetPullHandlers(LeftPullZone, false); - - var image = PinchImage; - SetPinchHandlers(image); - - var reset = ResetButton; + ("Touch / Pen", "Pull Gesture", + "Press and drag from colored border zones. A green ball tracks the pull delta and springs back on release.", + () => new GesturePullPage()), - reset.Click += (_, _) => - { - var compositionVisual = ElementComposition.GetElementVisual(image); + ("Multi Touch", "Pinch / Zoom", + "Pinch to scale an image using composition visuals. Scroll to pan when zoomed in.", + () => new GesturePinchZoomPage()), - if (compositionVisual != null) - { - _currentScale = 1; - compositionVisual.Scale = new(1, 1, 1); - compositionVisual.Offset = default; - image.InvalidateMeasure(); - } - }; + ("Multi Touch", "Pinch / Rotation", + "Pinch to rotate a rectangle. The Angle property from the pinch event drives a RotateTransform.", + () => new GesturePinchRotationPage()), - RotationGesture.AddHandler(InputElement.PinchEvent, (s, e) => - { - AngleSlider.Value = e.Angle; - }); - } + ("Touch / Pen / Mouse", "Swipe Gesture", + "Swipe horizontally or vertically. Configure direction, threshold, and mouse support. Shows live delta, velocity, and direction.", + () => new GestureSwipePage()), + }; - private void SetPinchHandlers(Control? control) + public GesturePage() { - if (control == null) - { - return; - } - - _currentScale = 1; - Vector3D currentOffset = default; - - CompositionVisual? compositionVisual = null; - - void InitComposition(Control visual) - { - if (compositionVisual != null) - { - return; - } - - compositionVisual = ElementComposition.GetElementVisual(visual); - } - - control.LayoutUpdated += (s, e) => - { - InitComposition(control!); - if (compositionVisual != null) - { - compositionVisual.Scale = new(_currentScale, _currentScale, 1); - - if (currentOffset == default) - { - currentOffset = compositionVisual.Offset; - } - } - }; - - control.AddHandler(InputElement.PinchEvent, (s, e) => - { - InitComposition(control!); - - if (compositionVisual != null) - { - var scale = _currentScale * (float)e.Scale; - - if (scale <= 1) - { - scale = 1; - compositionVisual.Offset = default; - } - - compositionVisual.Scale = new(scale, scale, 1); - - e.Handled = true; - } - }); - - control.AddHandler(InputElement.PinchEndedEvent, (s, e) => - { - InitComposition(control!); - - if (compositionVisual != null) - { - _currentScale = compositionVisual.Scale.X; - } - }); - - control.AddHandler(InputElement.ScrollGestureEvent, (s, e) => - { - InitComposition(control!); - - if (compositionVisual != null && _currentScale != 1) - { - currentOffset += new Vector3D(e.Delta.X, e.Delta.Y, 0); - - var currentSize = control.Bounds.Size * _currentScale; - - currentOffset = new Vector3D(Math.Clamp(currentOffset.X, 0, currentSize.Width - control.Bounds.Width), - (float)Math.Clamp(currentOffset.Y, 0, currentSize.Height - control.Bounds.Height), - 0); - - compositionVisual.Offset = currentOffset * -1; - - e.Handled = true; - } - }); + InitializeComponent(); + Loaded += OnLoaded; } - private void SetPullHandlers(Control control, bool inverse) + private async void OnLoaded(object? sender, RoutedEventArgs e) { - var ball = control.FindLogicalDescendantOfType(); - - Vector3D defaultOffset = default; - - CompositionVisual? ballCompositionVisual = null; - - if (ball != null) - { - InitComposition(ball); - } - else - { - return; - } - - control.LayoutUpdated += (s, e) => - { - InitComposition(ball!); - if (ballCompositionVisual != null) - { - defaultOffset = ballCompositionVisual.Offset; - } - }; - - control.AddHandler(InputElement.PullGestureEvent, (s, e) => - { - Vector3D center = new((float)control.Bounds.Center.X, (float)control.Bounds.Center.Y, 0); - InitComposition(ball!); - if (ballCompositionVisual != null) - { - ballCompositionVisual.Offset = defaultOffset + new Vector3D(e.Delta.X * 0.4f, e.Delta.Y * 0.4f, 0) * (inverse ? -1 : 1); - - e.Handled = true; - } - }); - - control.AddHandler(InputElement.PullGestureEndedEvent, (s, e) => - { - InitComposition(ball!); - if (ballCompositionVisual != null) - { - ballCompositionVisual.Offset = defaultOffset; - } - }); - - void InitComposition(Control control) - { - ballCompositionVisual = ElementComposition.GetElementVisual(ball); - - if (ballCompositionVisual != null) - { - var offsetAnimation = ballCompositionVisual.Compositor.CreateVector3KeyFrameAnimation(); - offsetAnimation.Target = "Offset"; - offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); - offsetAnimation.Duration = TimeSpan.FromMilliseconds(100); - - var implicitAnimations = ballCompositionVisual.Compositor.CreateImplicitAnimationCollection(); - implicitAnimations["Offset"] = offsetAnimation; - - ballCompositionVisual.ImplicitAnimations = implicitAnimations; - } - } + await SampleNav.PushAsync(NavigationDemoHelper.CreateGalleryHomePage(SampleNav, Demos), null); } } } diff --git a/samples/ControlCatalog/Pages/GesturePage.xaml b/samples/ControlCatalog/Pages/GesturePage.xaml index 00d36d6cea..bce18eab1d 100644 --- a/samples/ControlCatalog/Pages/GesturePage.xaml +++ b/samples/ControlCatalog/Pages/GesturePage.xaml @@ -1,170 +1,11 @@ - - - - Pull Gexture (Touch / Pen) - - - - Pull from colored rectangles - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Pinch/Zoom Gexture (Multi Touch) - - - - - - - - - - - - - - - - - Pinch/Rotation Gexture (Multi Touch) - - - - - - - - - - - - - - - - - - - - - + + + + + diff --git a/samples/ControlCatalog/Pages/Gestures/GesturePinchRotationPage.xaml b/samples/ControlCatalog/Pages/Gestures/GesturePinchRotationPage.xaml new file mode 100644 index 0000000000..b46af74c3b --- /dev/null +++ b/samples/ControlCatalog/Pages/Gestures/GesturePinchRotationPage.xaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/Gestures/GesturePinchRotationPage.xaml.cs b/samples/ControlCatalog/Pages/Gestures/GesturePinchRotationPage.xaml.cs new file mode 100644 index 0000000000..55d5e619ee --- /dev/null +++ b/samples/ControlCatalog/Pages/Gestures/GesturePinchRotationPage.xaml.cs @@ -0,0 +1,33 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; + +namespace ControlCatalog.Pages +{ + public partial class GesturePinchRotationPage : UserControl + { + private bool _isInit; + + public GesturePinchRotationPage() + { + InitializeComponent(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (_isInit) + { + return; + } + + _isInit = true; + + RotationGesture.AddHandler(InputElement.PinchEvent, (s, e) => + { + AngleSlider.Value = e.Angle; + }); + } + } +} diff --git a/samples/ControlCatalog/Pages/Gestures/GesturePinchZoomPage.xaml b/samples/ControlCatalog/Pages/Gestures/GesturePinchZoomPage.xaml new file mode 100644 index 0000000000..6932741e5b --- /dev/null +++ b/samples/ControlCatalog/Pages/Gestures/GesturePinchZoomPage.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + public static readonly StyledProperty TextProperty = - TextBlock.TextProperty.AddOwner(new(string.Empty, BindingMode.TwoWay)); + TextBlock.TextProperty.AddOwner(new(string.Empty, BindingMode.TwoWay, + enableDataValidation: true)); /// /// Defines the property. diff --git a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs index ca1fa66d91..578484557f 100644 --- a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs @@ -392,6 +392,28 @@ namespace Avalonia.Controls.UnitTests } + [Fact] + public void Text_Validation() + { + using (UnitTestApplication.Start(TestServices.MockThreadingInterface)) + { + var target = new ComboBox + { + Template = GetTemplate(), + }; + + target.ApplyTemplate(); + target.Presenter!.ApplyTemplate(); + + var exception = new System.InvalidCastException("failed validation"); + var textObservable = new BehaviorSubject(new BindingNotification(exception, BindingErrorType.DataValidationError)); + target.Bind(ComboBox.TextProperty, textObservable); + + Assert.True(DataValidationErrors.GetHasErrors(target)); + Assert.Equal([exception], DataValidationErrors.GetErrors(target)); + } + } + [Fact] public void Close_Window_On_Alt_F4_When_ComboBox_Is_Focus() { From a01245a78411816663c4b8ab53011ed04a69a433 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Thu, 26 Mar 2026 00:25:21 +0500 Subject: [PATCH 20/55] QoL for ControlCatalog: add sidebar search, auto-sort, and default page selection (#20987) * feat(ControlCatalog): add sidebar search, auto-sort, and default page selection - Add search TextBox (PART_SearchBox) to HamburgerMenu pane template that filters sidebar items by header text (case-insensitive) - Auto-sort TabItems alphabetically by Header on first load - Add IsDefaultPage attached property on HamburgerMenu to preselect a specific TabItem (set on Buttons page in MainView) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: replace IsDefaultPage attached property with simple IsSelected IsSelected on TabItem is sufficient for preselecting a page after auto-sort. Removes the custom IsDefaultPage attached property. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/ControlCatalog/MainView.xaml | 2 +- .../HamburgerMenu/HamburgerMenu.cs | 68 ++++++++++++++++++- .../HamburgerMenu/HamburgerMenu.xaml | 17 ++++- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index adea1b90fc..3a6d57801a 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -16,7 +16,7 @@ - + diff --git a/samples/SampleControls/HamburgerMenu/HamburgerMenu.cs b/samples/SampleControls/HamburgerMenu/HamburgerMenu.cs index 57f8a138af..0e45b28a78 100644 --- a/samples/SampleControls/HamburgerMenu/HamburgerMenu.cs +++ b/samples/SampleControls/HamburgerMenu/HamburgerMenu.cs @@ -1,4 +1,6 @@ -using Avalonia; +using System; +using System.Linq; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Media; @@ -8,6 +10,8 @@ namespace ControlSamples public class HamburgerMenu : TabControl { private SplitView? _splitView; + private TextBox? _searchBox; + private bool _initialized; public static readonly StyledProperty PaneBackgroundProperty = SplitView.PaneBackgroundProperty.AddOwner(); @@ -41,6 +45,68 @@ namespace ControlSamples base.OnApplyTemplate(e); _splitView = e.NameScope.Find("PART_NavigationPane"); + _searchBox = e.NameScope.Find("PART_SearchBox"); + + if (_searchBox is not null) + { + _searchBox.TextChanged += OnSearchTextChanged; + } + } + + protected override void OnLoaded(Avalonia.Interactivity.RoutedEventArgs e) + { + base.OnLoaded(e); + + if (!_initialized) + { + _initialized = true; + SortItems(); + } + } + + private void SortItems() + { + var items = Items.OfType().ToList(); + var sorted = items.OrderBy(t => t.Header?.ToString() ?? "", StringComparer.OrdinalIgnoreCase).ToList(); + + // Only reorder if needed + bool needsSort = false; + for (int i = 0; i < items.Count; i++) + { + if (!ReferenceEquals(items[i], sorted[i])) + { + needsSort = true; + break; + } + } + + if (needsSort) + { + Items.Clear(); + foreach (var item in sorted) + { + Items.Add(item); + } + } + } + + private void OnSearchTextChanged(object? sender, TextChangedEventArgs e) + { + var searchText = _searchBox?.Text; + var hasFilter = !string.IsNullOrWhiteSpace(searchText); + + foreach (var item in Items.OfType()) + { + if (hasFilter) + { + var header = item.Header?.ToString() ?? ""; + item.IsVisible = header.Contains(searchText!, StringComparison.OrdinalIgnoreCase); + } + else + { + item.IsVisible = true; + } + } } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) diff --git a/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml b/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml index 49972bdc64..d9b3e3c73f 100644 --- a/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml +++ b/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml @@ -175,10 +175,21 @@ OpenPaneLength="{StaticResource PaneExpandWidth}" PaneBackground="Transparent"> - + + + + + + @@ -195,7 +206,7 @@ internal partial class TopLevelHost : Control { + private Thickness _decorationInset; + static TopLevelHost() { KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(KeyboardNavigationMode.Cycle); @@ -25,5 +28,89 @@ internal partial class TopLevelHost : Control VisualChildren.Add(tl); } + /// + /// Gets or sets the decoration inset applied to the TopLevel child in forced decoration mode. + /// When non-zero, the TopLevel is measured and arranged within the inset area while + /// decoration layers use the full available size. + /// + internal Thickness DecorationInset + { + get => _decorationInset; + set + { + if (_decorationInset == value) + return; + _decorationInset = value; + InvalidateMeasure(); + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var inset = _decorationInset; + var hasInset = inset != default; + var desiredSize = default(Size); + + foreach (var child in VisualChildren) + { + if (child is Layoutable l) + { + if (hasInset && ReferenceEquals(child, _topLevel)) + { + // In forced mode, measure the TopLevel with reduced size + var contentSize = new Size( + Math.Max(0, availableSize.Width - inset.Left - inset.Right), + Math.Max(0, availableSize.Height - inset.Top - inset.Bottom)); + l.Measure(contentSize); + + // Add inset back so TopLevelHost's desired size represents the full frame. + // This ensures ArrangeOverride receives the full frame size and can correctly + // position the TopLevel within the inset area. + desiredSize = new Size( + Math.Max(desiredSize.Width, l.DesiredSize.Width + inset.Left + inset.Right), + Math.Max(desiredSize.Height, l.DesiredSize.Height + inset.Top + inset.Bottom)); + } + else + { + l.Measure(availableSize); + + desiredSize = new Size( + Math.Max(desiredSize.Width, l.DesiredSize.Width), + Math.Max(desiredSize.Height, l.DesiredSize.Height)); + } + } + } + + return desiredSize; + } + + protected override Size ArrangeOverride(Size finalSize) + { + var inset = _decorationInset; + var hasInset = inset != default; + + foreach (var child in VisualChildren) + { + if (child is Layoutable l) + { + if (hasInset && ReferenceEquals(child, _topLevel)) + { + // In forced mode, arrange the TopLevel within the inset area + var contentSize = new Size( + Math.Max(0, finalSize.Width - inset.Left - inset.Right), + Math.Max(0, finalSize.Height - inset.Top - inset.Bottom)); + + l.Arrange(new Rect(inset.Left, inset.Top, contentSize.Width, contentSize.Height)); + } + else + { + l.Arrange(new Rect(finalSize)); + } + } + } + + return finalSize; + } + protected override bool BypassFlowDirectionPolicies => true; } diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 80349634b7..4dab3574eb 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -98,6 +98,7 @@ namespace Avalonia.Controls private Thickness _offScreenMargin; private bool _canHandleResized = false; private Size _arrangeBounds; + private bool _isForcedDecorationMode; /// /// Defines the property. @@ -249,7 +250,11 @@ namespace Avalonia.Controls impl.WindowStateChanged = HandleWindowStateChanged; _maxPlatformClientSize = PlatformImpl?.MaxAutoSizeHint ?? default(Size); impl.ExtendClientAreaToDecorationsChanged = ExtendClientAreaToDecorationsChanged; - this.GetObservable(ClientSizeProperty).Skip(1).Subscribe(x => PlatformImpl?.Resize(x, WindowResizeReason.Application)); + this.GetObservable(ClientSizeProperty).Skip(1).Subscribe(x => + { + ResizePlatformImpl(x, WindowResizeReason.Application); + }); + ScalingChanged += OnScalingChangedUpdateDecorations; CreatePlatformImplBinding(TitleProperty, title => PlatformImpl!.SetTitle(title)); CreatePlatformImplBinding(IconProperty, SetEffectiveIcon); @@ -668,7 +673,7 @@ namespace Avalonia.Controls UpdateDrawnDecorationParts(); } - protected virtual void ExtendClientAreaToDecorationsChanged(bool isExtended) + private void ExtendClientAreaToDecorationsChanged(bool isExtended) { IsExtendedIntoWindowDecorations = isExtended; OffScreenMargin = PlatformImpl?.OffScreenMargin ?? default; @@ -679,6 +684,10 @@ namespace Avalonia.Controls private void UpdateDrawnDecorations() { var parts = ComputeDecorationParts(); + + // Detect forced mode: platform needs managed decorations but app hasn't opted in + _isForcedDecorationMode = parts != null && !IsExtendedIntoWindowDecorations; + TopLevelHost.UpdateDrawnDecorations(parts, WindowState); if (parts != null) @@ -687,6 +696,8 @@ namespace Avalonia.Controls var decorations = TopLevelHost.Decorations; if (decorations != null) { + decorations.RenderScaling = RenderScaling; + var hint = ExtendClientAreaTitleBarHeightHint; if (hint >= 0) decorations.TitleBarHeightOverride = hint; @@ -696,6 +707,13 @@ namespace Avalonia.Controls UpdateDrawnDecorationMargins(); } + private void OnScalingChangedUpdateDecorations(object? sender, EventArgs e) + { + var decorations = TopLevelHost.Decorations; + if (decorations != null) + decorations.RenderScaling = RenderScaling; + } + /// /// Updates decoration parts based on current window state without /// re-creating the decorations instance. @@ -753,7 +771,9 @@ namespace Avalonia.Controls var decorations = TopLevelHost.Decorations; if (decorations == null) { + // Only use platform margins if drawn decorations are not active WindowDecorationMargin = PlatformImpl?.ExtendedMargins ?? default; + TopLevelHost.DecorationInset = default; return; } @@ -764,11 +784,25 @@ namespace Avalonia.Controls ? decorations.FrameThickness : default; var shadow = parts.HasFlag(Chrome.DrawnWindowDecorationParts.Shadow) ? decorations.ShadowThickness : default; - WindowDecorationMargin = new Thickness( + var margin = new Thickness( frame.Left + shadow.Left, titleBarHeight + frame.Top + shadow.Top, frame.Right + shadow.Right, frame.Bottom + shadow.Bottom); + + if (_isForcedDecorationMode) + { + // In forced mode, app is unaware of decorations. + // TopLevelHost insets the Window child; WindowDecorationMargin stays zero. + WindowDecorationMargin = default; + TopLevelHost.DecorationInset = margin; + } + else + { + // In extended mode, app handles the margin itself. + WindowDecorationMargin = margin; + TopLevelHost.DecorationInset = default; + } } private void OnTitleBarHeightHintChanged() @@ -933,6 +967,15 @@ namespace Avalonia.Controls // Enable drawn decorations before layout so margins are computed UpdateDrawnDecorations(); + // In forced mode, adjust ClientSize to reflect usable content area + if (_isForcedDecorationMode) + { + var inset = TopLevelHost.DecorationInset; + ClientSize = new Size( + Math.Max(0, ClientSize.Width - inset.Left - inset.Right), + Math.Max(0, ClientSize.Height - inset.Top - inset.Bottom)); + } + _shown = true; IsVisible = true; @@ -978,10 +1021,18 @@ namespace Avalonia.Controls DesktopScalingOverride = null; - if (clientSizeChanged || ClientSize != PlatformImpl?.ClientSize) + // In forced mode, compare against adjusted platform size + var platformClientSize = PlatformImpl?.ClientSize ?? default; + var comparableClientSize = _isForcedDecorationMode + ? new Size( + Math.Max(0, platformClientSize.Width - TopLevelHost.DecorationInset.Left - TopLevelHost.DecorationInset.Right), + Math.Max(0, platformClientSize.Height - TopLevelHost.DecorationInset.Top - TopLevelHost.DecorationInset.Bottom)) + : platformClientSize; + + if (clientSizeChanged || ClientSize != comparableClientSize) { // Previously it was called before ExecuteInitialLayoutPass - PlatformImpl?.Resize(ClientSize, WindowResizeReason.Layout); + ResizePlatformImpl(ClientSize, WindowResizeReason.Layout); // we do not want PlatformImpl?.Resize to trigger HandleResized yet because it will set Width and Height. // So perform some important actions from HandleResized @@ -1037,6 +1088,22 @@ namespace Avalonia.Controls } } + private void ResizePlatformImpl(Size size, WindowResizeReason reason) + { + // In forced mode, add decoration inset so platform gets full frame size + if (_isForcedDecorationMode) + { + var inset = TopLevelHost.DecorationInset; + size = new Size( + size.Width + inset.Left + inset.Right, + size.Height + inset.Top + inset.Bottom); + if (PlatformImpl?.ClientSize != size) + PlatformImpl?.Resize(size, reason); + } + else + PlatformImpl?.Resize(size, reason); + } + /// /// Shows the window as a dialog. /// @@ -1272,6 +1339,14 @@ namespace Avalonia.Controls { var sizeToContent = SizeToContent; var clientSize = ClientSize; + if (_isForcedDecorationMode) + { + clientSize = PlatformImpl?.ClientSize ?? clientSize; + var inset = TopLevelHost.DecorationInset; + clientSize = new Size( + Math.Max(0, clientSize.Width - inset.Left - inset.Right), + Math.Max(0, clientSize.Height - inset.Top - inset.Bottom)); + } var maxAutoSize = PlatformImpl?.MaxAutoSizeHint ?? Size.Infinity; var useAutoWidth = sizeToContent.HasAllFlags(SizeToContent.Width); var useAutoHeight = sizeToContent.HasAllFlags(SizeToContent.Height); @@ -1332,7 +1407,9 @@ namespace Avalonia.Controls { _arrangeBounds = size; if (_canHandleResized) - PlatformImpl?.Resize(size, WindowResizeReason.Layout); + { + ResizePlatformImpl(size, WindowResizeReason.Layout); + } return ClientSize; } @@ -1350,6 +1427,16 @@ namespace Avalonia.Controls /// internal override void HandleResized(Size clientSize, WindowResizeReason reason) { + // In forced decoration mode, the platform's clientSize includes decoration area. + // Subtract the decoration inset so Window.ClientSize reflects the usable content area. + if (_isForcedDecorationMode) + { + var inset = TopLevelHost.DecorationInset; + clientSize = new Size( + Math.Max(0, clientSize.Width - inset.Left - inset.Right), + Math.Max(0, clientSize.Height - inset.Top - inset.Bottom)); + } + if (_canHandleResized && (ClientSize != clientSize || double.IsNaN(Width) || double.IsNaN(Height))) { var sizeToContent = SizeToContent; diff --git a/src/Avalonia.Controls/WindowBase.cs b/src/Avalonia.Controls/WindowBase.cs index 07966600e6..894b7ca6e6 100644 --- a/src/Avalonia.Controls/WindowBase.cs +++ b/src/Avalonia.Controls/WindowBase.cs @@ -304,7 +304,7 @@ namespace Avalonia.Controls { var constraint = ArrangeSetBounds(finalRect.Size); var arrangeSize = ArrangeOverride(constraint); - Bounds = new Rect(arrangeSize); + Bounds = new Rect(finalRect.Position, arrangeSize); } /// diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index 566b0d907a..a3f98265f4 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -482,13 +482,32 @@ namespace Avalonia , Message = "Experimental, used mostly for testing" #endif )] - public bool? EnableDrawnDecorations - { - get => EnableDrawnDecorationsInternal; - set => EnableDrawnDecorationsInternal = value; - } + public bool? EnableDrawnDecorations { get; set; } + + internal bool EnableDrawnDecorationsInternal => +#pragma warning disable AVALONIA_X11_CSD + EnableDrawnDecorations == true || ForceDrawnDecorationsInternal; +#pragma warning restore AVALONIA_X11_CSD + + + /// + /// Forces client-side drawn window decorations on X11 for all windows, + /// even when the app has not opted in via ExtendClientAreaToDecorationsHint. + /// In this mode, Window.ClientSize reflects the usable content area + /// (platform client size minus decoration margins) and the app is unaware + /// of the decorations. + /// Implies EnableDrawnDecorations = true. + /// + [Experimental("AVALONIA_X11_FORCE_CSD" + #if NET10_0_OR_GREATER + , Message = "Experimental, used mostly for testing" + #endif + )] + public bool ForceDrawnDecorations { get; set; } - internal bool? EnableDrawnDecorationsInternal { get; set; } +#pragma warning disable AVALONIA_X11_FORCE_CSD + internal bool ForceDrawnDecorationsInternal => ForceDrawnDecorations; +#pragma warning restore AVALONIA_X11_FORCE_CSD /// /// If Avalonia is in control of a run loop, we propagate exceptions by stopping the run loop frame diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 0cfa226163..23c5cc18db 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -926,7 +926,7 @@ namespace Avalonia.X11 mouse.Position = mouse.Position / RenderScaling; // Chrome hit-test for drawn decorations - if (_extendClientAreaToDecorations + if (UseManagedDecorations && mouse.Type == RawPointerEventType.LeftButtonDown && _inputRoot is { } inputRoot) { @@ -1187,8 +1187,8 @@ namespace Avalonia.X11 private void UpdateEffectiveSystemDecorations() { - // When extending client area, always hide WM decorations (we draw our own) - var effective = _extendClientAreaToDecorations + // When extending client area or forcing drawn decorations, always hide WM decorations (we draw our own) + var effective = UseManagedDecorations ? WindowDecorations.None : (_requestedWindowDecorations == WindowDecorations.Full ? WindowDecorations.Full @@ -1509,17 +1509,18 @@ namespace Avalonia.X11 } } - private bool _extendClientAreaToDecorations; + private bool _extendingClientAreaToDecorations; + private bool UseManagedDecorations => _extendingClientAreaToDecorations || _platform.Options.ForceDrawnDecorationsInternal; public void SetExtendClientAreaToDecorationsHint(bool extendIntoClientAreaHint) { - if (_platform.Options.EnableDrawnDecorationsInternal != true) + if (!_platform.Options.EnableDrawnDecorationsInternal) return; - if (_extendClientAreaToDecorations == extendIntoClientAreaHint) + if (_extendingClientAreaToDecorations == extendIntoClientAreaHint) return; - _extendClientAreaToDecorations = extendIntoClientAreaHint; + _extendingClientAreaToDecorations = extendIntoClientAreaHint; UpdateEffectiveSystemDecorations(); IsClientAreaExtendedToDecorations = extendIntoClientAreaHint; @@ -1606,10 +1607,10 @@ namespace Avalonia.X11 public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = new AcrylicPlatformCompensationLevels(1, 0.8, 0.8); - public bool NeedsManagedDecorations => _extendClientAreaToDecorations; + public bool NeedsManagedDecorations => UseManagedDecorations; public PlatformRequestedDrawnDecoration RequestedDrawnDecorations => - _extendClientAreaToDecorations + UseManagedDecorations ? PlatformRequestedDrawnDecoration.Border | PlatformRequestedDrawnDecoration.ResizeGrips | PlatformRequestedDrawnDecoration.TitleBar diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 0882516f57..9233cc2809 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -1188,6 +1188,7 @@ namespace Avalonia.Win32 { if (!_shown) { + ExtendClientAreaToDecorationsChanged?.Invoke(_isClientAreaExtended); return; } diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index 5347acbc33..63cc2db193 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Reactive.Linq; using System.Threading.Tasks; +using Avalonia.Controls.Platform; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Threading; @@ -1164,6 +1165,236 @@ namespace Avalonia.Controls.UnitTests } } + public class ForcedDecorationSizingTests : ScopedTestBase + { + /// + /// Creates a mock IWindowImpl that simulates forced CSD mode: + /// NeedsManagedDecorations = true, RequestedDrawnDecorations includes TitleBar + Border, + /// but IsClientAreaExtendedToDecorations = false. + /// + private static Mock CreateForcedCsdWindowMock( + double initialWidth = 800, double initialHeight = 600) + { + var windowImpl = MockWindowingPlatform.CreateWindowMock(initialWidth, initialHeight); + + windowImpl.Setup(x => x.NeedsManagedDecorations).Returns(true); + windowImpl.Setup(x => x.RequestedDrawnDecorations).Returns( + PlatformRequestedDrawnDecoration.TitleBar | PlatformRequestedDrawnDecoration.Border); + + return windowImpl; + } + + [Fact] + public void ClientSize_Should_Exclude_Decoration_Inset() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.Manual, + }; + + // Verify mock setup + Assert.True(windowImpl.Object.NeedsManagedDecorations); + + target.Show(); + + var host = target.TopLevelHost; + var decorations = host.Decorations; + + // Debug: verify decorations were created + Assert.NotNull(decorations); + Assert.True(decorations!.TitleBarHeight > 0, + $"TitleBarHeight was {decorations.TitleBarHeight}"); + + var inset = host.DecorationInset; + Assert.NotEqual(default, inset); + + var expectedClientSize = new Size( + 800 - inset.Left - inset.Right, + 600 - inset.Top - inset.Bottom); + Assert.Equal(expectedClientSize, target.ClientSize); + } + } + + [Fact] + public void WindowDecorationMargin_Should_Be_Zero_In_Forced_Mode() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.Manual, + }; + + target.Show(); + + Assert.Equal(default(Thickness), target.WindowDecorationMargin); + } + } + + [Fact] + public void HandleResized_Should_Subtract_Inset_From_Platform_Size() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.Manual, + }; + + target.Show(); + + var inset = target.TopLevelHost.DecorationInset; + + // Simulate a platform resize (e.g. user resize) + target.PlatformImpl!.Resized!.Invoke(new Size(1000, 700), WindowResizeReason.User); + + var expectedClientSize = new Size( + 1000 - inset.Left - inset.Right, + 700 - inset.Top - inset.Bottom); + Assert.Equal(expectedClientSize, target.ClientSize); + } + } + + [Fact] + public void Setting_Width_Should_Resize_WindowImpl_With_Inset_Added() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + Width = 400, + Height = 300, + SizeToContent = SizeToContent.Manual, + }; + + target.Show(); + + var inset = target.TopLevelHost.DecorationInset; + + target.Width = 500; + target.LayoutManager.ExecuteLayoutPass(); + + // Platform should receive full frame size (content + inset) + var expectedPlatformSize = new Size( + 500 + inset.Left + inset.Right, + 300 + inset.Top + inset.Bottom); + windowImpl.Verify(x => x.Resize(expectedPlatformSize, WindowResizeReason.Layout)); + } + } + + [Fact] + public void Child_Should_Be_Measured_With_Content_Size() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var child = new ChildControl(); + var target = new Window(windowImpl.Object) + { + Width = 400, + Height = 300, + SizeToContent = SizeToContent.Manual, + Content = child, + }; + + target.Show(); + + Assert.Equal(1, child.MeasureSizes.Count); + Assert.Equal(new Size(400, 300), child.MeasureSizes[0]); + } + } + + [Fact] + public void Width_Height_Should_Not_Be_NaN_After_Show() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.Manual, + }; + + target.Show(); + + Assert.False(double.IsNaN(target.Width)); + Assert.False(double.IsNaN(target.Height)); + + var inset = target.TopLevelHost.DecorationInset; + Assert.Equal(800 - inset.Left - inset.Right, target.Width); + Assert.Equal(600 - inset.Top - inset.Bottom, target.Height); + } + } + + [Fact] + public void SizeToContent_Should_Work_In_Forced_Mode() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var child = new Canvas + { + Width = 400, + Height = 300, + }; + + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.WidthAndHeight, + Content = child, + }; + + target.Show(); + + Assert.Equal(400, target.Width); + Assert.Equal(300, target.Height); + Assert.Equal(SizeToContent.WidthAndHeight, target.SizeToContent); + } + } + + [Fact] + public void User_Resize_Should_Reset_SizeToContent() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var child = new Canvas + { + Width = 400, + Height = 300, + }; + + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.WidthAndHeight, + Content = child, + }; + + target.Show(); + Assert.Equal(400, target.Width); + Assert.Equal(300, target.Height); + + var inset = target.TopLevelHost.DecorationInset; + // Platform fires resize with full frame size + var newPlatformWidth = 500 + inset.Left + inset.Right; + var newPlatformHeight = 300 + inset.Top + inset.Bottom; + windowImpl.Object.Resized?.Invoke( + new Size(newPlatformWidth, newPlatformHeight), + WindowResizeReason.User); + + Assert.Equal(500, target.Width); + Assert.Equal(300, target.Height); + Assert.Equal(SizeToContent.Height, target.SizeToContent); + } + } + } + private static Mock CreateImpl() { var screen1 = new MockScreen(1.75, new PixelRect(new PixelSize(1920, 1080)), new PixelRect(new PixelSize(1920, 966)), true); From 2dfd8515a74fac33842b9025f1d666988b6d4413 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Thu, 26 Mar 2026 16:25:25 +0100 Subject: [PATCH 22/55] Use the correct value for animations stopped during a visual tree detach (#20995) * Add failing test for FillMode on visual tree detach * Update animation fill value when detached from visual tree --- .../Animation/AnimationInstance`1.cs | 15 ++++++-- .../Animation/AnimationIterationTests.cs | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Base/Animation/AnimationInstance`1.cs b/src/Avalonia.Base/Animation/AnimationInstance`1.cs index 390a4a10b4..757e5f8987 100644 --- a/src/Avalonia.Base/Animation/AnimationInstance`1.cs +++ b/src/Avalonia.Base/Animation/AnimationInstance`1.cs @@ -148,7 +148,11 @@ namespace Avalonia.Animation } // Stop and dispose the animation when detached from the visual tree. - _detachedHandler = (_, _) => DoComplete(); + _detachedHandler = (_, _) => + { + SetFinalValue(); + DoComplete(); + }; visual.DetachedFromVisualTree += _detachedHandler; } @@ -172,6 +176,12 @@ namespace Avalonia.Animation } } + private void SetFinalValue() + { + var easedTime = _easeFunc!.Ease(_playbackReversed ? 0.0 : 1.0); + _lastInterpValue = _interpolator(easedTime, _neutralValue); + } + private void ApplyFinalFill() { if (_animator.Property is null) @@ -237,8 +247,7 @@ namespace Avalonia.Animation // when the duration is set to zero while animating and snap to the last iterated value. if (_currentIteration + 1 > _iterationCount || _duration == TimeSpan.Zero) { - var easedTime = _easeFunc!.Ease(_playbackReversed ? 0.0 : 1.0); - _lastInterpValue = _interpolator(easedTime, _neutralValue); + SetFinalValue(); DoComplete(); return; } diff --git a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs index 0ca5a3be6a..b32cb5a7d1 100644 --- a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs @@ -1264,6 +1264,44 @@ namespace Avalonia.Base.UnitTests.Animation Assert.Equal(200d, border.Width); } + [Fact] + public void FillMode_Applies_Final_Value_When_Visual_Detached_During_Animation() + { + var keyframe1 = new KeyFrame + { + Setters = { new Setter(Layoutable.WidthProperty, 100d) }, + Cue = new Cue(0d) + }; + var keyframe2 = new KeyFrame + { + Setters = { new Setter(Layoutable.WidthProperty, 300d) }, + Cue = new Cue(1d) + }; + + var animation = new Animation + { + Duration = TimeSpan.FromSeconds(5), + IterationCount = new IterationCount(1), + FillMode = FillMode.Forward, + Children = { keyframe1, keyframe2 } + }; + + var border = new Border { Height = 100d, Width = 50d }; + var root = new TestRoot(border); + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + clock.Step(TimeSpan.Zero); + Assert.Equal(100d, border.Width); + + // Detach from visual tree immediately + root.Child = null; + + // The final value should be applied + Assert.True(animationRun.IsCompleted); + Assert.Equal(300d, border.Width); + } + private sealed class FakeAnimator : InterpolatingAnimator { public double LastProgress { get; set; } = double.NaN; From edb89bd16651fd799ecce6d5bbd39c8b397f318d Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Fri, 27 Mar 2026 09:21:51 +0100 Subject: [PATCH 23/55] Remove LambdaExpression.Compile from CompiledBinding.Create (#20996) --- .../Core/Parsers/BindingExpressionVisitor.cs | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs b/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs index 8525dd8493..841bcb19a7 100644 --- a/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs +++ b/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs @@ -303,29 +303,14 @@ internal class BindingExpressionVisitor(LambdaExpression expression) : Expr } } - private static Func? CreateGetter(PropertyInfo info) + private static Func? CreateGetter(PropertyInfo info) { - if (info.GetMethod == null) - return null; - var target = Expression.Parameter(typeof(object), "target"); - return Expression.Lambda>( - Expression.Convert(Expression.Call(Expression.Convert(target, info.DeclaringType!), info.GetMethod), - typeof(object)), - target) - .Compile(); + return info.CanRead ? info.GetValue : null; } private static Action? CreateSetter(PropertyInfo info) { - if (info.SetMethod == null) - return null; - var target = Expression.Parameter(typeof(object), "target"); - var value = Expression.Parameter(typeof(object), "value"); - return Expression.Lambda>( - Expression.Call(Expression.Convert(target, info.DeclaringType!), info.SetMethod, - Expression.Convert(value, info.SetMethod.GetParameters()[0].ParameterType)), - target, value) - .Compile(); + return info.CanWrite ? info.SetValue : null; } private static T GetValue(Expression expr) From cdf129941cbc8b482d4825ef32f25b7b5b4f2dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Fri, 27 Mar 2026 16:12:51 +0100 Subject: [PATCH 24/55] Add DrawerHeaderTemplate and DrawerFooterTemplate properties to DrawerPage (#21008) * Add DrawerHeaderTemplate and DrawerFooterTemplate properties to DrawerPage * Updated sample --- .../DrawerPageCustomizationPage.xaml | 16 ++ .../DrawerPageCustomizationPage.xaml.cs | 125 +++++++++++- src/Avalonia.Controls/Page/DrawerPage.cs | 32 +++ .../Controls/DrawerPage.xaml | 2 + .../Controls/DrawerPage.xaml | 2 + .../DrawerPageTests.cs | 187 ++++++++++++++++++ 6 files changed, 362 insertions(+), 2 deletions(-) diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml index 4987e8979e..3adffed5f7 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml +++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml @@ -72,6 +72,14 @@ + + + + + + + @@ -82,6 +90,14 @@ + + + + + + + diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs index 243bc5868b..0a81133bae 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs @@ -1,8 +1,10 @@ using System.Linq; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Input.GestureRecognizers; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.Media; namespace ControlCatalog.Pages @@ -164,7 +166,7 @@ namespace ControlCatalog.Pages if (!_isLoaded) return; if (ShowHeaderCheck.IsChecked == true) - DemoDrawer.DrawerHeader = DrawerHeaderBorder; + DemoDrawer.DrawerHeader = HeaderTemplateCombo.SelectedIndex == 0 ? DrawerHeaderBorder : (object)"My Application"; else DemoDrawer.DrawerHeader = null; } @@ -174,9 +176,128 @@ namespace ControlCatalog.Pages if (!_isLoaded) return; if (ShowFooterCheck.IsChecked == true) - DemoDrawer.DrawerFooter = DrawerFooterBorder; + { + DemoDrawer.DrawerFooter = FooterTemplateCombo.SelectedIndex switch + { + 1 => (object)"v12.0", + 2 => (object)"Avalonia", + _ => DrawerFooterBorder + }; + } else + { DemoDrawer.DrawerFooter = null; + } + } + + private void OnHeaderTemplateChanged(object? sender, SelectionChangedEventArgs e) + { + if (!_isLoaded) + return; + + switch (HeaderTemplateCombo.SelectedIndex) + { + case 1: + DemoDrawer.DrawerHeader = "My Application"; + DemoDrawer.DrawerHeaderTemplate = new FuncDataTemplate((data, _) => + new Border + { + Padding = new Avalonia.Thickness(16), + Child = new StackPanel + { + Spacing = 2, + Children = + { + new TextBlock { Text = data, FontSize = 18, FontWeight = FontWeight.SemiBold, Foreground = Brushes.White }, + new TextBlock { Text = "Navigation", FontSize = 12, Foreground = Brushes.White, Opacity = 0.7 } + } + } + }); + break; + + case 2: + DemoDrawer.DrawerHeader = "My Application"; + DemoDrawer.DrawerHeaderTemplate = new FuncDataTemplate((data, _) => + { + var initial = data?.Length > 0 ? data[0].ToString().ToUpperInvariant() : "?"; + var avatar = new Border + { + Width = 40, + Height = 40, + CornerRadius = new Avalonia.CornerRadius(20), + Background = new SolidColorBrush(Color.Parse("#1976D2")), + Child = new TextBlock + { + Text = initial, + FontSize = 18, + FontWeight = FontWeight.Bold, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + var label = new TextBlock { Text = data, FontSize = 14, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center }; + var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 }; + row.Children.Add(avatar); + row.Children.Add(label); + return new Border { Padding = new Avalonia.Thickness(12), Child = row }; + }); + break; + + default: + DemoDrawer.DrawerHeader = DrawerHeaderBorder; + DemoDrawer.DrawerHeaderTemplate = null; + break; + } + } + + private void OnFooterTemplateChanged(object? sender, SelectionChangedEventArgs e) + { + if (!_isLoaded) + return; + + switch (FooterTemplateCombo.SelectedIndex) + { + case 1: + DemoDrawer.DrawerFooter = "v12.0"; + DemoDrawer.DrawerFooterTemplate = new FuncDataTemplate((data, _) => + new Border + { + Padding = new Avalonia.Thickness(12, 8), + Child = new Border + { + Padding = new Avalonia.Thickness(8, 4), + CornerRadius = new Avalonia.CornerRadius(4), + Background = new SolidColorBrush(Color.Parse("#1976D2")), + Child = new TextBlock { Text = data, FontSize = 11, Foreground = Brushes.White, FontWeight = FontWeight.SemiBold } + } + }); + break; + + case 2: + DemoDrawer.DrawerFooter = "Avalonia"; + DemoDrawer.DrawerFooterTemplate = new FuncDataTemplate((data, _) => + { + var icon = new PathIcon + { + Width = 14, + Height = 14, + Data = Geometry.Parse("M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"), + Opacity = 0.5 + }; + var label = new TextBlock { Text = data, FontSize = 12, Opacity = 0.6, VerticalAlignment = VerticalAlignment.Center }; + var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; + row.Children.Add(icon); + row.Children.Add(label); + return new Border { Padding = new Avalonia.Thickness(14, 10), Child = row }; + }); + break; + + default: + DemoDrawer.DrawerFooter = DrawerFooterBorder; + DemoDrawer.DrawerFooterTemplate = null; + break; + } } private void OnMenuItemClick(object? sender, RoutedEventArgs e) diff --git a/src/Avalonia.Controls/Page/DrawerPage.cs b/src/Avalonia.Controls/Page/DrawerPage.cs index f8e039f8cd..e2cefeb5d9 100644 --- a/src/Avalonia.Controls/Page/DrawerPage.cs +++ b/src/Avalonia.Controls/Page/DrawerPage.cs @@ -124,6 +124,18 @@ namespace Avalonia.Controls public static readonly StyledProperty DrawerFooterProperty = AvaloniaProperty.Register(nameof(DrawerFooter)); + /// + /// Defines the property. + /// + public static readonly StyledProperty DrawerHeaderTemplateProperty = + AvaloniaProperty.Register(nameof(DrawerHeaderTemplate)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty DrawerFooterTemplateProperty = + AvaloniaProperty.Register(nameof(DrawerFooterTemplate)); + /// /// Defines the property. /// @@ -403,6 +415,7 @@ namespace Avalonia.Controls /// /// Gets or sets the header content displayed at the top of the drawer pane. /// + [DependsOn(nameof(DrawerHeaderTemplate))] public object? DrawerHeader { get => GetValue(DrawerHeaderProperty); @@ -412,12 +425,31 @@ namespace Avalonia.Controls /// /// Gets or sets the footer content displayed at the bottom of the drawer pane. /// + [DependsOn(nameof(DrawerFooterTemplate))] public object? DrawerFooter { get => GetValue(DrawerFooterProperty); set => SetValue(DrawerFooterProperty, value); } + /// + /// Gets or sets the data template used to display . + /// + public IDataTemplate? DrawerHeaderTemplate + { + get => GetValue(DrawerHeaderTemplateProperty); + set => SetValue(DrawerHeaderTemplateProperty, value); + } + + /// + /// Gets or sets the data template used to display . + /// + public IDataTemplate? DrawerFooterTemplate + { + get => GetValue(DrawerFooterTemplateProperty); + set => SetValue(DrawerFooterTemplateProperty, value); + } + /// /// Gets or sets the icon displayed in the drawer toggle button. /// diff --git a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml index 985814c967..2a5a672d6b 100644 --- a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml @@ -57,11 +57,13 @@ + new FuncControlTemplate((dp, scope) => + { + var header = new ContentPresenter { Name = "PART_DrawerHeader" }.RegisterInNameScope(scope); + header.Bind(ContentPresenter.ContentProperty, dp.GetObservable(DrawerPage.DrawerHeaderProperty)); + header.Bind(ContentPresenter.ContentTemplateProperty, dp.GetObservable(DrawerPage.DrawerHeaderTemplateProperty)); + + var footer = new ContentPresenter { Name = "PART_DrawerFooter" }.RegisterInNameScope(scope); + footer.Bind(ContentPresenter.ContentProperty, dp.GetObservable(DrawerPage.DrawerFooterProperty)); + footer.Bind(ContentPresenter.ContentTemplateProperty, dp.GetObservable(DrawerPage.DrawerFooterTemplateProperty)); + + return new StackPanel { Children = { header, footer } }; + }); + + private static (DrawerPage dp, ContentPresenter header, ContentPresenter footer, TestRoot root) Create( + object? drawerHeader = null, + IDataTemplate? headerTemplate = null, + object? drawerFooter = null, + IDataTemplate? footerTemplate = null) + { + var dp = new DrawerPage + { + Template = MinimalPaneTemplate(), + DrawerHeader = drawerHeader, + DrawerHeaderTemplate = headerTemplate, + DrawerFooter = drawerFooter, + DrawerFooterTemplate = footerTemplate, + }; + var root = new TestRoot { Child = dp }; + dp.ApplyTemplate(); + + var header = dp.GetVisualDescendants().OfType().First(x => x.Name == "PART_DrawerHeader"); + var footer = dp.GetVisualDescendants().OfType().First(x => x.Name == "PART_DrawerFooter"); + + return (dp, header, footer, root); + } + + [Fact] + public void DrawerHeaderTemplate_IsForwardedToContentPresenter() + { + var template = new FuncDataTemplate((_, _) => new TextBlock()); + var (_, header, _, _) = Create(drawerHeader: "App", headerTemplate: template); + + Assert.Same(template, header.ContentTemplate); + } + + [Fact] + public void DrawerFooterTemplate_IsForwardedToContentPresenter() + { + var template = new FuncDataTemplate((_, _) => new TextBlock()); + var (_, _, footer, _) = Create(drawerFooter: "v1.0", footerTemplate: template); + + Assert.Same(template, footer.ContentTemplate); + } + + [Fact] + public void DrawerHeaderTemplate_RendersControlProducedByFactory() + { + var (_, header, _, _) = Create( + drawerHeader: "App", + headerTemplate: new FuncDataTemplate((_, _) => new Canvas())); + + header.UpdateChild(); + + Assert.IsType(header.Child); + } + + [Fact] + public void DrawerFooterTemplate_RendersControlProducedByFactory() + { + var (_, _, footer, _) = Create( + drawerFooter: "v1.0", + footerTemplate: new FuncDataTemplate((_, _) => new Canvas())); + + footer.UpdateChild(); + + Assert.IsType(footer.Child); + } + + [Fact] + public void DrawerHeaderTemplate_ReceivesDrawerHeaderAsData() + { + object? receivedData = null; + var (_, header, _, _) = Create( + drawerHeader: "MyTitle", + headerTemplate: new FuncDataTemplate((data, _) => + { + receivedData = data; + return new TextBlock { Text = data }; + })); + + header.UpdateChild(); + + Assert.Equal("MyTitle", receivedData); + } + + [Fact] + public void DrawerFooterTemplate_ReceivesDrawerFooterAsData() + { + object? receivedData = null; + var (_, _, footer, _) = Create( + drawerFooter: "v2.0", + footerTemplate: new FuncDataTemplate((data, _) => + { + receivedData = data; + return new TextBlock { Text = data }; + })); + + footer.UpdateChild(); + + Assert.Equal("v2.0", receivedData); + } + + [Fact] + public void DrawerHeaderTemplate_SwapTemplate_UpdatesContentPresenter() + { + var second = new FuncDataTemplate((_, _) => new Border()); + var (dp, header, _, _) = Create( + drawerHeader: "App", + headerTemplate: new FuncDataTemplate((_, _) => new Canvas())); + + header.UpdateChild(); + Assert.IsType(header.Child); + + dp.DrawerHeaderTemplate = second; + header.UpdateChild(); + + Assert.IsType(header.Child); + } + + [Fact] + public void DrawerFooterTemplate_SwapTemplate_UpdatesContentPresenter() + { + var second = new FuncDataTemplate((_, _) => new Border()); + var (dp, _, footer, _) = Create( + drawerFooter: "v1.0", + footerTemplate: new FuncDataTemplate((_, _) => new Canvas())); + + footer.UpdateChild(); + Assert.IsType(footer.Child); + + dp.DrawerFooterTemplate = second; + footer.UpdateChild(); + + Assert.IsType(footer.Child); + } + + [Fact] + public void DrawerHeaderTemplate_ClearingTemplate_FallsBackToDirectContent() + { + var directControl = new TextBlock { Text = "Direct" }; + var (dp, header, _, _) = Create( + drawerHeader: directControl, + headerTemplate: new FuncDataTemplate((_, _) => new Canvas())); + + header.UpdateChild(); + Assert.IsType(header.Child); + + dp.DrawerHeaderTemplate = null; + header.UpdateChild(); + + Assert.Same(directControl, header.Child); + } + + [Fact] + public void DrawerFooterTemplate_ClearingTemplate_FallsBackToDirectContent() + { + var directControl = new TextBlock { Text = "Direct" }; + var (dp, _, footer, _) = Create( + drawerFooter: directControl, + footerTemplate: new FuncDataTemplate((_, _) => new Canvas())); + + footer.UpdateChild(); + Assert.IsType(footer.Child); + + dp.DrawerFooterTemplate = null; + footer.UpdateChild(); + + Assert.Same(directControl, footer.Child); + } + } + public class SwipeGestureTests : ScopedTestBase { [Fact] From 093976c0650c60137828b89b1bf3368364ed0e29 Mon Sep 17 00:00:00 2001 From: Dong Bin <14807942+rabbitism@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:26:50 +0800 Subject: [PATCH 25/55] feat: include VML itself in layer lookup. (#20999) --- .../Primitives/AdornerLayer.cs | 2 +- .../Primitives/OverlayLayer.cs | 2 +- .../Primitives/PopupOverlayLayer.cs | 2 +- .../Primitives/TextSelectorLayer.cs | 2 +- .../Primitives/VisualLayerManagerTests.cs | 78 +++++++++++++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Controls/Primitives/AdornerLayer.cs b/src/Avalonia.Controls/Primitives/AdornerLayer.cs index 1c8b24f627..e9a4fe4368 100644 --- a/src/Avalonia.Controls/Primitives/AdornerLayer.cs +++ b/src/Avalonia.Controls/Primitives/AdornerLayer.cs @@ -73,7 +73,7 @@ namespace Avalonia.Controls.Primitives public static AdornerLayer? GetAdornerLayer(Visual visual) { // Check if the visual is inside an OverlayLayer with a dedicated AdornerLayer - foreach (var ancestor in visual.GetVisualAncestors()) + foreach (var ancestor in visual.GetSelfAndVisualAncestors()) { if (GetDirectAdornerLayer(ancestor) is { } adornerLayer) return adornerLayer; diff --git a/src/Avalonia.Controls/Primitives/OverlayLayer.cs b/src/Avalonia.Controls/Primitives/OverlayLayer.cs index a9d9b072f2..057995a8c5 100644 --- a/src/Avalonia.Controls/Primitives/OverlayLayer.cs +++ b/src/Avalonia.Controls/Primitives/OverlayLayer.cs @@ -29,7 +29,7 @@ namespace Avalonia.Controls.Primitives /// The associated with the visual, or null if no overlay layer exists. public static OverlayLayer? GetOverlayLayer(Visual visual) { - foreach (var v in visual.GetVisualAncestors()) + foreach (var v in visual.GetSelfAndVisualAncestors()) if (v is VisualLayerManager { OverlayLayer: { } layer }) return layer; diff --git a/src/Avalonia.Controls/Primitives/PopupOverlayLayer.cs b/src/Avalonia.Controls/Primitives/PopupOverlayLayer.cs index 4ca54e3d8f..a335ba080a 100644 --- a/src/Avalonia.Controls/Primitives/PopupOverlayLayer.cs +++ b/src/Avalonia.Controls/Primitives/PopupOverlayLayer.cs @@ -11,7 +11,7 @@ namespace Avalonia.Controls.Primitives public static PopupOverlayLayer? GetPopupOverlayLayer(Visual visual) { - foreach (var v in visual.GetVisualAncestors()) + foreach (var v in visual.GetSelfAndVisualAncestors()) if (v is VisualLayerManager { PopupOverlayLayer: { } layer }) return layer; diff --git a/src/Avalonia.Controls/Primitives/TextSelectorLayer.cs b/src/Avalonia.Controls/Primitives/TextSelectorLayer.cs index d6b080e588..87cd1fe419 100644 --- a/src/Avalonia.Controls/Primitives/TextSelectorLayer.cs +++ b/src/Avalonia.Controls/Primitives/TextSelectorLayer.cs @@ -11,7 +11,7 @@ namespace Avalonia.Controls.Primitives public static TextSelectorLayer? GetTextSelectorLayer(Visual visual) { - foreach (var v in visual.GetVisualAncestors()) + foreach (var v in visual.GetSelfAndVisualAncestors()) if (v is VisualLayerManager { TextSelectorLayer: { } textSelectorLayer }) return textSelectorLayer; diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/VisualLayerManagerTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/VisualLayerManagerTests.cs index 70c7d946f9..6c195f4a3b 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/VisualLayerManagerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/VisualLayerManagerTests.cs @@ -33,5 +33,83 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.NotNull(mainAdornerLayer); Assert.NotSame(overlayAdornerLayer, mainAdornerLayer); } + + [Fact] + public void GetAdornerLayer_Returns_Same_AdornerLayer_For_VisualLayerManager() + { + var vlm = new VisualLayerManager(); + var root = new TestRoot { Child = vlm }; + + root.Measure(new Size(100, 100)); + root.Arrange(new Rect(0, 0, 100, 100)); + + var adornerLayer = vlm.AdornerLayer; + Assert.NotNull(adornerLayer); + + // The adorner layer for a control inside the OverlayLayer + // should be the dedicated one, not the main VLM adorner layer. + var target = AdornerLayer.GetAdornerLayer(vlm); + Assert.NotNull(target); + Assert.Same(adornerLayer, target); + } + + [Fact] + public void GetAdornerLayer_Returns_Same_AdornerLayer_For_Child() + { + var button = new Button(); + var vlm = new VisualLayerManager() { Child = button }; + var root = new TestRoot { Child = vlm }; + + root.Measure(new Size(100, 100)); + root.Arrange(new Rect(0, 0, 100, 100)); + + var adornerLayer = vlm.AdornerLayer; + Assert.NotNull(adornerLayer); + + // The adorner layer for a control inside the OverlayLayer + // should be the dedicated one, not the main VLM adorner layer. + var target = AdornerLayer.GetAdornerLayer(button); + Assert.NotNull(target); + Assert.Same(adornerLayer, target); + } + + [Fact] + public void GetOverlayLayer_Returns_Same_OverlayLayer_For_VisualLayerManager() + { + var vlm = new VisualLayerManager() { EnableOverlayLayer = true }; + var root = new TestRoot { Child = vlm }; + + root.Measure(new Size(100, 100)); + root.Arrange(new Rect(0, 0, 100, 100)); + + var overlayLayer = vlm.OverlayLayer; + Assert.NotNull(overlayLayer); + + // The adorner layer for a control inside the OverlayLayer + // should be the dedicated one, not the main VLM adorner layer. + var target = OverlayLayer.GetOverlayLayer(vlm); + Assert.NotNull(target); + Assert.Same(overlayLayer, target); + } + + [Fact] + public void GetOverlayLayer_Returns_Same_OverlayLayer_For_Child() + { + var button = new Button(); + var vlm = new VisualLayerManager() { EnableOverlayLayer = true, Child = button }; + var root = new TestRoot { Child = vlm }; + + root.Measure(new Size(100, 100)); + root.Arrange(new Rect(0, 0, 100, 100)); + + var overlayLayer = vlm.OverlayLayer; + Assert.NotNull(overlayLayer); + + // The adorner layer for a control inside the OverlayLayer + // should be the dedicated one, not the main VLM adorner layer. + var target = OverlayLayer.GetOverlayLayer(button); + Assert.NotNull(target); + Assert.Same(overlayLayer, target); + } } } From b18b38521fb18ba650d817a46c28f52be65213a7 Mon Sep 17 00:00:00 2001 From: Mike James Date: Sat, 28 Mar 2026 08:17:05 +0100 Subject: [PATCH 26/55] Update readme.md - Added Security section - Removed community portal links as we shut it down earlier in the month. --- readme.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/readme.md b/readme.md index 08fd014798..722e302c03 100644 --- a/readme.md +++ b/readme.md @@ -54,11 +54,6 @@ Avalonia development is supported by the generous sponsorship of [Devolutions](h devolutions-color-hr - -## Community -Join our community hub to get early access to upcoming features, share your thoughts, and connect directly with the Avalonia team. -[![communityannouncement-banner 1](https://github.com/user-attachments/assets/21950b56-cd28-4574-9a0a-73bb17b89d31)](https://avaloniaui.community) - ## Bleeding Edge Builds We also have a [nightly build](https://github.com/AvaloniaUI/Avalonia/wiki/Using-nightly-build-feed) which tracks the current state of master. Although these packages are less stable than the release on NuGet.org, you'll get all the latest features and bugfixes right away and many of our users actually prefer this feed! @@ -85,6 +80,16 @@ This project exists thanks to all the people who contribute. Please read the [contribution guidelines](CONTRIBUTING.md) before submitting a pull request. +## Security + +If you discover a security vulnerability in any of our SDKs, tools, services, or repositories, please help us keep the community safe by reporting it responsibly. + +You can report security vulnerabilities by emailing **[security@avaloniaui.net](mailto:security@avaloniaui.net)**. + +Please avoid disclosing the issue publicly until we have had a reasonable amount of time to investigate and release a patch or mitigation. We review all legitimate reports and will work with you to quickly resolve the issue. + +Please note that Avalonia does not operate a bug bounty programme. + ## Code of Conduct This project has adopted the code of conduct defined by the Contributor Covenant to clarify expected behavior in our community. From 680fab0124f14ecb9156997f9d24f0345a2c5ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Sat, 28 Mar 2026 10:05:21 +0100 Subject: [PATCH 27/55] Fix DrawerPage double footer in compact mode (#21004) --- .../Controls/DrawerPage.xaml | 16 ++ .../Controls/DrawerPage.xaml | 16 ++ .../Controls/DrawerPageTests.cs | 258 +++++++++++++++++- ...ompactInline_Closed_ShowsRail.expected.png | Bin 0 -> 1729 bytes ...mpactOverlay_Closed_ShowsRail.expected.png | Bin 0 -> 1724 bytes ...mpactInline_Closed_ShowsRail.expected.png} | Bin ...nline_Open_PanePushesContent.expected.png} | Bin ...pactOverlay_Closed_ShowsRail.expected.png} | Bin ...lay_Open_PaneOverlaysContent.expected.png} | Bin ...ompactInline_Closed_ShowsRail.expected.png | Bin 0 -> 1745 bytes ...mpactOverlay_Closed_ShowsRail.expected.png | Bin 0 -> 1759 bytes ...ompactInline_Closed_ShowsRail.expected.png | Bin 0 -> 1730 bytes ...mpactOverlay_Closed_ShowsRail.expected.png | Bin 0 -> 1725 bytes 13 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_BottomPlacement_CompactInline_Closed_ShowsRail.expected.png create mode 100644 tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_BottomPlacement_CompactOverlay_Closed_ShowsRail.expected.png rename tests/TestFiles/Skia/Controls/DrawerPage/{DrawerPage_CompactInline_Closed_ShowsRail.expected.png => DrawerPage_LeftPlacement_CompactInline_Closed_ShowsRail.expected.png} (100%) rename tests/TestFiles/Skia/Controls/DrawerPage/{DrawerPage_CompactInline_Open_PanePushesContent.expected.png => DrawerPage_LeftPlacement_CompactInline_Open_PanePushesContent.expected.png} (100%) rename tests/TestFiles/Skia/Controls/DrawerPage/{DrawerPage_CompactOverlay_Closed_ShowsRail.expected.png => DrawerPage_LeftPlacement_CompactOverlay_Closed_ShowsRail.expected.png} (100%) rename tests/TestFiles/Skia/Controls/DrawerPage/{DrawerPage_CompactOverlay_Open_PaneOverlaysContent.expected.png => DrawerPage_LeftPlacement_CompactOverlay_Open_PaneOverlaysContent.expected.png} (100%) create mode 100644 tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_RightPlacement_CompactInline_Closed_ShowsRail.expected.png create mode 100644 tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_RightPlacement_CompactOverlay_Closed_ShowsRail.expected.png create mode 100644 tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_TopPlacement_CompactInline_Closed_ShowsRail.expected.png create mode 100644 tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_TopPlacement_CompactOverlay_Closed_ShowsRail.expected.png diff --git a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml index 2a5a672d6b..612db8d69c 100644 --- a/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/DrawerPage.xaml @@ -200,6 +200,22 @@ + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml b/src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml index c1a448541d..42445ff4c3 100644 --- a/src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml +++ b/src/Avalonia.Themes.Simple/Controls/DrawerPage.xaml @@ -183,6 +183,22 @@ + + + + + + + + diff --git a/tests/Avalonia.RenderTests/Controls/DrawerPageTests.cs b/tests/Avalonia.RenderTests/Controls/DrawerPageTests.cs index a26bc14e83..961b959c17 100644 --- a/tests/Avalonia.RenderTests/Controls/DrawerPageTests.cs +++ b/tests/Avalonia.RenderTests/Controls/DrawerPageTests.cs @@ -139,7 +139,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls } [Fact] - public async Task DrawerPage_CompactOverlay_Closed_ShowsRail() + public async Task DrawerPage_LeftPlacement_CompactOverlay_Closed_ShowsRail() { var target = new Decorator { @@ -181,7 +181,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls } [Fact] - public async Task DrawerPage_CompactOverlay_Open_PaneOverlaysContent() + public async Task DrawerPage_LeftPlacement_CompactOverlay_Open_PaneOverlaysContent() { var target = new Decorator { @@ -222,7 +222,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls } [Fact] - public async Task DrawerPage_CompactInline_Closed_ShowsRail() + public async Task DrawerPage_LeftPlacement_CompactInline_Closed_ShowsRail() { var target = new Decorator { @@ -264,7 +264,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls } [Fact] - public async Task DrawerPage_CompactInline_Open_PanePushesContent() + public async Task DrawerPage_LeftPlacement_CompactInline_Open_PanePushesContent() { var target = new Decorator { @@ -460,5 +460,255 @@ namespace Avalonia.Direct2D1.RenderTests.Controls await RenderToFile(target); CompareImages(skipImmediate: true); } + + [Fact] + public async Task DrawerPage_RightPlacement_CompactOverlay_Closed_ShowsRail() + { + var target = new Decorator + { + Width = 500, + Height = 350, + Child = new DrawerPage + { + Background = Brushes.White, + DrawerLength = 200, + DrawerLayoutBehavior = DrawerLayoutBehavior.CompactOverlay, + CompactDrawerLength = 48, + DrawerPlacement = DrawerPlacement.Right, + DrawerBackground = new SolidColorBrush(Color.Parse("#E8EAF6")), + Drawer = new StackPanel + { + Margin = new Thickness(0, 4), + Children = + { + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#3949AB")), HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 8) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#E53935")), HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 4) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#43A047")), HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 4) }, + } + }, + Content = new Border + { + Width = 120, + Height = 80, + Background = new SolidColorBrush(Color.Parse("#DCEEFB")), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + } + }; + + target.Styles.Add(new SimpleTheme()); + await RenderToFile(target); + CompareImages(skipImmediate: true); + } + + [Fact] + public async Task DrawerPage_RightPlacement_CompactInline_Closed_ShowsRail() + { + var target = new Decorator + { + Width = 500, + Height = 350, + Child = new DrawerPage + { + Background = Brushes.White, + DrawerLength = 200, + DrawerLayoutBehavior = DrawerLayoutBehavior.CompactInline, + CompactDrawerLength = 48, + DrawerPlacement = DrawerPlacement.Right, + DrawerBackground = new SolidColorBrush(Color.Parse("#E8F5E9")), + Drawer = new StackPanel + { + Margin = new Thickness(0, 4), + Children = + { + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#2E7D32")), HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 8) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#E53935")), HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 4) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#FB8C00")), HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 4) }, + } + }, + Content = new Border + { + Width = 120, + Height = 80, + Background = new SolidColorBrush(Color.Parse("#DCEEFB")), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + } + }; + + target.Styles.Add(new SimpleTheme()); + await RenderToFile(target); + CompareImages(skipImmediate: true); + } + + [Fact] + public async Task DrawerPage_TopPlacement_CompactOverlay_Closed_ShowsRail() + { + var target = new Decorator + { + Width = 500, + Height = 350, + Child = new DrawerPage + { + Background = Brushes.White, + DrawerLength = 200, + DrawerLayoutBehavior = DrawerLayoutBehavior.CompactOverlay, + CompactDrawerLength = 48, + DrawerPlacement = DrawerPlacement.Top, + DrawerBackground = new SolidColorBrush(Color.Parse("#E8EAF6")), + Drawer = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(4, 0), + Children = + { + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#3949AB")), Margin = new Thickness(8, 0) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#E53935")), Margin = new Thickness(4, 0) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#43A047")), Margin = new Thickness(4, 0) }, + } + }, + Content = new Border + { + Width = 120, + Height = 80, + Background = new SolidColorBrush(Color.Parse("#DCEEFB")), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + } + }; + + target.Styles.Add(new SimpleTheme()); + await RenderToFile(target); + CompareImages(skipImmediate: true); + } + + [Fact] + public async Task DrawerPage_TopPlacement_CompactInline_Closed_ShowsRail() + { + var target = new Decorator + { + Width = 500, + Height = 350, + Child = new DrawerPage + { + Background = Brushes.White, + DrawerLength = 200, + DrawerLayoutBehavior = DrawerLayoutBehavior.CompactInline, + CompactDrawerLength = 48, + DrawerPlacement = DrawerPlacement.Top, + DrawerBackground = new SolidColorBrush(Color.Parse("#E8F5E9")), + Drawer = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(4, 0), + Children = + { + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#2E7D32")), Margin = new Thickness(8, 0) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#E53935")), Margin = new Thickness(4, 0) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#FB8C00")), Margin = new Thickness(4, 0) }, + } + }, + Content = new Border + { + Width = 120, + Height = 80, + Background = new SolidColorBrush(Color.Parse("#DCEEFB")), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + } + }; + + target.Styles.Add(new SimpleTheme()); + await RenderToFile(target); + CompareImages(skipImmediate: true); + } + + [Fact] + public async Task DrawerPage_BottomPlacement_CompactOverlay_Closed_ShowsRail() + { + var target = new Decorator + { + Width = 500, + Height = 350, + Child = new DrawerPage + { + Background = Brushes.White, + DrawerLength = 200, + DrawerLayoutBehavior = DrawerLayoutBehavior.CompactOverlay, + CompactDrawerLength = 48, + DrawerPlacement = DrawerPlacement.Bottom, + DrawerBackground = new SolidColorBrush(Color.Parse("#E8EAF6")), + Drawer = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(4, 0), + Children = + { + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#3949AB")), Margin = new Thickness(8, 0) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#E53935")), Margin = new Thickness(4, 0) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#43A047")), Margin = new Thickness(4, 0) }, + } + }, + Content = new Border + { + Width = 120, + Height = 80, + Background = new SolidColorBrush(Color.Parse("#DCEEFB")), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + } + }; + + target.Styles.Add(new SimpleTheme()); + await RenderToFile(target); + CompareImages(skipImmediate: true); + } + + [Fact] + public async Task DrawerPage_BottomPlacement_CompactInline_Closed_ShowsRail() + { + var target = new Decorator + { + Width = 500, + Height = 350, + Child = new DrawerPage + { + Background = Brushes.White, + DrawerLength = 200, + DrawerLayoutBehavior = DrawerLayoutBehavior.CompactInline, + CompactDrawerLength = 48, + DrawerPlacement = DrawerPlacement.Bottom, + DrawerBackground = new SolidColorBrush(Color.Parse("#E8F5E9")), + Drawer = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(4, 0), + Children = + { + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#2E7D32")), Margin = new Thickness(8, 0) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#E53935")), Margin = new Thickness(4, 0) }, + new Border { Width = 24, Height = 24, Background = new SolidColorBrush(Color.Parse("#FB8C00")), Margin = new Thickness(4, 0) }, + } + }, + Content = new Border + { + Width = 120, + Height = 80, + Background = new SolidColorBrush(Color.Parse("#DCEEFB")), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + } + }; + + target.Styles.Add(new SimpleTheme()); + await RenderToFile(target); + CompareImages(skipImmediate: true); + } } } diff --git a/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_BottomPlacement_CompactInline_Closed_ShowsRail.expected.png b/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_BottomPlacement_CompactInline_Closed_ShowsRail.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..676905c809a6bb877740fb270b9dc1d055da139e GIT binary patch literal 1729 zcmeAS@N?(olHy`uVBq!ia0y~yVEh8aaU5(wk>bF&OhAgI*vT`50|;t3QaTtI*eX0- z978JRyuD+X6%r`Y@KD@XT7X;pftrhAQuiS@Jvtnv-97#8s8~j_0zKd^qVuEZTuMygzYx3Vqi$9Y~o{J9K}N`47x8eAK-}3 zuj`R(G_5MR?=M~6&nwHo#4?J>3WKs|mGj*B85wkrw~c0oAz2u7A3l8eqOnfu`?H%b zDl;4?=+5k3Tv1qLSH;3`gClMyvqQrI!uUYa7l!}0SC_B9UiGiN`EvcQf9I#?UpG5# z;(Phs&5!Z+^&kE{jGAA+w|?uj&x}9LqNkPZ`$WD!`uO?fr_)cp+A21EFR`++jXD|0 zP#~Urr0w6|XSZJnG9;|NTjl)!$Ir`I>WmC|xyoiG3<;L7(l-z2&I4&VFFxB;F)`f8 zf^iuR1i>=)og(&zhTa<&{!X_BCRW{Y296sYz>FW!hvd5hYxR%juI6W7zS#Z8VW7J& zJdb}L&s%H9usYG1y@A1zFm5;s^zqS?KVMifG^qYzdZ_DiF1b_t9I#Ge@O1TaS?83{ F1OWOgT?7CC literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_BottomPlacement_CompactOverlay_Closed_ShowsRail.expected.png b/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_BottomPlacement_CompactOverlay_Closed_ShowsRail.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..62f2b83f846d98e085ae67c4ae27b83f6b7e1c4b GIT binary patch literal 1724 zcmeAS@N?(olHy`uVBq!ia0y~yVEh8aaU5(wk>bF&OhAgI*vT`50|;t3QaTtI*or+} z978JRyuGtAD>zZ2;bHJ>=?_wSI0IL7MQeMcv#($4yu4{)z){y6YfEm~y=<@7$*`-7 zA6)X$KW=`y?~n85PoG-{MpT7Qn zA$Vf}`}e+8GqGcZIS)GjBkhy!M;2l3W*f6Ep>*Z1%H_v*KOoZZbEI+sm+ ze?Jee|MTbS?#I1$|GyOMy!e^%$I&)E2F4~NtR3G~{qN1glH$)l{n{$N{79(UTvM-} z&UoN|hPK(4{O#KpyE8P*-S(;N-^0htch6;Eh`1S+^Mc_(6^kqb6A0faVsB`8d*j0M z$}cPo8!p4Rj15~ktQc58c*j$qebE~i{?;#Ni0F@g#~`plRN9JRgPbFK0|OK{Ow~X7 zcGc697hee0ZLY5~{ql2p`{{+}*&pN)o46AUeJ_{PRvK3EF(mNXGlu6mKiiQiAp@+A O7(8A5T-G@yGywqd8viH& literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_CompactInline_Closed_ShowsRail.expected.png b/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_LeftPlacement_CompactInline_Closed_ShowsRail.expected.png similarity index 100% rename from tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_CompactInline_Closed_ShowsRail.expected.png rename to tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_LeftPlacement_CompactInline_Closed_ShowsRail.expected.png diff --git a/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_CompactInline_Open_PanePushesContent.expected.png b/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_LeftPlacement_CompactInline_Open_PanePushesContent.expected.png similarity index 100% rename from tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_CompactInline_Open_PanePushesContent.expected.png rename to tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_LeftPlacement_CompactInline_Open_PanePushesContent.expected.png diff --git a/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_CompactOverlay_Closed_ShowsRail.expected.png b/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_LeftPlacement_CompactOverlay_Closed_ShowsRail.expected.png similarity index 100% rename from tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_CompactOverlay_Closed_ShowsRail.expected.png rename to tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_LeftPlacement_CompactOverlay_Closed_ShowsRail.expected.png diff --git a/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_CompactOverlay_Open_PaneOverlaysContent.expected.png b/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_LeftPlacement_CompactOverlay_Open_PaneOverlaysContent.expected.png similarity index 100% rename from tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_CompactOverlay_Open_PaneOverlaysContent.expected.png rename to tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_LeftPlacement_CompactOverlay_Open_PaneOverlaysContent.expected.png diff --git a/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_RightPlacement_CompactInline_Closed_ShowsRail.expected.png b/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_RightPlacement_CompactInline_Closed_ShowsRail.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..d180c53f290790af523f80b477f5aaf12e3b6469 GIT binary patch literal 1745 zcmeAS@N?(olHy`uVBq!ia0y~yVEh8aaU5(wk>bF&OhAgI*vT`50|;t3QaTtI*g8C2 z978JRyuE9fC6Xx7@UZtCYf~%x2~`(IM^}Np9(fN0_cXS1L`X6%Y6|-{=@N5}!xgou zpKjk^$=NjdgZldO!RLA6H@-Q&-Fo+C<6{!q3_s4x%E+9NOPu)i4I{&YRXhH_Irv6! z+ozWw9=|&FMvj#S?r-(#>+b8fTU~GCV+cQT z{=NM+I}nq5&;LJ(-#AuJ<&|ZSf>~744wTq&T9&~gyJxNc)`iRmnBEn!GwAg!V0LH# z;rAC96Bx^PGBc$A&@X40aU}K~!+}=85(W+dGVlkR5{4hIFF!nfQ#mI$y~i+(pOIm6 zuAhHe|AvnKJB$hr7l^|L4o5O1aQnyE?Y;7Pe{EsOy2rUM7z7k(gf{@=Z*x0C!%eg0 z>YLpojsU}wv583h`X)2OfzmI(KYsS!`|oUd{<+dUz~Cn4(jx>z1s)Uw>Av(R<7G*K67J zW>M;iYn#~_8eYG+{V(ksM>;-d5}PE3qB>9%{FuHsR^PYb^M-}c3bF&OhAgI*vT`50|;t3QaTtI*rs{9 zIEGZrd3$$nmPn#V!^7CS(kD!xsJR@Je-k2*&AtBYA*}>~kenkKsk#wWjEb#MO9Sns zrR!(CN%{Hc_2(<7Z^hQGdZ8pg;zGQ2Byk!-2Qa;bHRjbr#dJWA~QTe*N;{YS{Bz7Z@Kj{rL6t z^feGOq49eDz4LO3TQ@Cac4%I}?9c$h?|~AGf;JcQ~vVSjfQ@a#jp~zPx+!^NrxPZF0#yH=P*{3^FTc*eV`+2wBQE71Hk;}#@t?o{tX!k)t^1#^F+V@s z#>c?eL;(ioi5a#mf8Jewp1;{Wq5a!;v(4@e_A>M5oRdowq-A6h6X64;22?cEZ{GiW sDQAHguDHMzoulHollpK9n;(qRQzDKY_$tu^tg#q8UHx3vIVCg!0Q@10rT_o{ literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_TopPlacement_CompactInline_Closed_ShowsRail.expected.png b/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_TopPlacement_CompactInline_Closed_ShowsRail.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..079e54a59da5d1d6d939689118a24612f63dc22a GIT binary patch literal 1730 zcmeAS@N?(olHy`uVBq!ia0y~yVEh8aaU5(wk>bF&OhAgI*vT`50|;t3QaTtI*eX3; z978JRyuD+a6%r`X@KAa(Z$~S4f$$;$M^}Nn9%U0$cQAjSpcLxluxLWaw@H^+mvCH+ z+bI@i*7W)D%*xL;Z+V{AUENbN{qP$ZS=qCf#ctPs&iTT@U~uQ&mZO1tZ1zgnGBM<| zR4-;{V3?-nI$>1GfY#4I_tqSYX+8_V{LZl7ppTgtbGRM@-gVR zUSL#k0OA9>@P3&%{O}|M{G*KmO4hd*qbw-ZTjVy7$W+3Wf_=QM)8mfgF43d Y%UzFO@BZZntdtl$UHx3vIVCg!0Fe_gDF6Tf literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_TopPlacement_CompactOverlay_Closed_ShowsRail.expected.png b/tests/TestFiles/Skia/Controls/DrawerPage/DrawerPage_TopPlacement_CompactOverlay_Closed_ShowsRail.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..70994e46232d9d589d2f546bada8cc34a60581d3 GIT binary patch literal 1725 zcmeAS@N?(olHy`uVBq!ia0y~yVEh8aaU5(wk>bF&OhAgI*vT`50|;t3QaTtI*h)NI z978JRyuGtAD>zZ2;bHJ>=?_wSI0IL7MQeMcv#($4Je{x2NmP8}+b23DU!>OTE|QrO z!!r4peP!)D(}(u=r%ZqUVe;P1n>Sb9N@Vws?~!90dVe^B5S)W(B8*&7%@ zc>5hjg#>N0lK)Tn7+x2imStetmgLONa83CIgMb1QZ}_xyN9Fc|lXJlBDm_P4%Uhx_m}=JPi#scCbf(-HE+&-w&|B+0_Iy_=4E0T#X~L(>X?>a Xak+1J@7*?FeZ=7D>gTe~DWM4fAC5u6 literal 0 HcmV?d00001 From 9f0319a749c3b7e82f9b1f496a24ec6fc9e11282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Sat, 28 Mar 2026 10:37:47 +0100 Subject: [PATCH 28/55] Fix TabbedPage samples icon (#21003) --- .../CarouselPage/CareCompanionAppPage.xaml.cs | 4 +- .../Pages/CarouselPage/SanctuaryMainPage.xaml | 8 +-- .../DrawerPageCustomizationPage.xaml.cs | 2 +- .../Pages/DrawerPage/EcoTrackerAppPage.xaml | 8 +-- .../NavigationPage/LAvenirAppPage.xaml.cs | 51 ++++++------------- .../Pages/NavigationPage/PulseAppPage.xaml.cs | 6 +-- .../NavigationPage/RetroGamingAppPage.xaml.cs | 43 +++------------- src/Avalonia.Controls/TabItem.cs | 19 +++++++ 8 files changed, 54 insertions(+), 87 deletions(-) diff --git a/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs index f7c87f56a3..bc8be3ea87 100644 --- a/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs @@ -703,7 +703,7 @@ public partial class CareCompanionAppPage : UserControl var home = BuildHomeTab(); home.Header = "Home"; - home.Icon = Geometry.Parse("M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"); + home.Icon = new PathIcon { Data = Geometry.Parse("M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z") }; tp.Pages = new ObservableCollection { @@ -729,7 +729,7 @@ public partial class CareCompanionAppPage : UserControl => new ContentPage { Header = header, - Icon = Geometry.Parse(iconData), + Icon = new PathIcon { Data = Geometry.Parse(iconData) }, Background = new SolidColorBrush(BgLight), Content = new StackPanel { diff --git a/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml index b701ab89ba..d8d5f322c3 100644 --- a/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml +++ b/samples/ControlCatalog/Pages/CarouselPage/SanctuaryMainPage.xaml @@ -93,7 +93,7 @@ - M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z + @@ -243,7 +243,7 @@ - M12 10.9c-.61 0-1.1.49-1.1 1.1s.49 1.1 1.1 1.1c.61 0 1.1-.49 1.1-1.1s-.49-1.1-1.1-1.1zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm2.19 12.19L6 18l3.81-8.19L18 6l-3.81 8.19z + @@ -260,7 +260,7 @@ - M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z + @@ -277,7 +277,7 @@ - M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z + diff --git a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs index 0a81133bae..d948252385 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DrawerPage/DrawerPageCustomizationPage.xaml.cs @@ -145,7 +145,7 @@ namespace ControlCatalog.Pages { if (!_isLoaded) return; - DemoDrawer.DrawerIcon = Geometry.Parse(_iconPaths[IconCombo.SelectedIndex]); + DemoDrawer.DrawerIcon = new PathIcon { Data = Geometry.Parse(_iconPaths[IconCombo.SelectedIndex]) }; } private void OnBackdropChanged(object? sender, SelectionChangedEventArgs e) diff --git a/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml b/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml index 1e9106ccfe..22320fbc8d 100644 --- a/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml +++ b/samples/ControlCatalog/Pages/DrawerPage/EcoTrackerAppPage.xaml @@ -52,13 +52,9 @@ - M12 3C9 6 6 9 6 13C6 17.4 8.7 21 12 22C15.3 21 18 17.4 18 13C18 9 15 6 12 3Z + - - - - - diff --git a/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs index beb0b2dccb..3ccdcaefa8 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/LAvenirAppPage.xaml.cs @@ -59,26 +59,6 @@ public partial class LAvenirAppPage : UserControl _infoPanel.IsVisible = Bounds.Width >= 650; } - void ApplyRootNavigationBarAppearance() - { - if (_navPage == null) - return; - - _navPage.Background = new SolidColorBrush(BgLight); - _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgLight); - _navPage.Resources["NavigationBarForeground"] = new SolidColorBrush(TextDark); - } - - void ApplyDetailNavigationBarAppearance() - { - if (_navPage == null) - return; - - _navPage.Background = new SolidColorBrush(BgDark); - _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgDark); - _navPage.Resources["NavigationBarForeground"] = Brushes.White; - } - TabbedPage BuildMenuTabbedPage() { var tp = new TabbedPage @@ -112,7 +92,6 @@ public partial class LAvenirAppPage : UserControl VerticalAlignment = VerticalAlignment.Center, TextAlignment = TextAlignment.Center, }; - ApplyRootNavigationBarAppearance(); NavigationPage.SetTopCommandBar(tp, new Button { @@ -140,7 +119,7 @@ public partial class LAvenirAppPage : UserControl Content = menuView, Background = new SolidColorBrush(BgLight), Header = "Menu", - Icon = Geometry.Parse("M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z"), + Icon = new PathIcon { Data = Geometry.Parse("M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z") }, }; var reservationsPage = new ContentPage @@ -148,7 +127,7 @@ public partial class LAvenirAppPage : UserControl Content = new LAvenirReservationsView(), Background = new SolidColorBrush(BgLight), Header = "Reservations", - Icon = Geometry.Parse("M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z"), + Icon = new PathIcon { Data = Geometry.Parse("M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z") }, }; var profilePage = new ContentPage @@ -156,7 +135,7 @@ public partial class LAvenirAppPage : UserControl Content = new LAvenirProfileView(), Background = new SolidColorBrush(BgLight), Header = "Profile", - Icon = Geometry.Parse("M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"), + Icon = new PathIcon { Data = Geometry.Parse("M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z") }, }; tp.Pages = new ObservableCollection { menuPage, reservationsPage, profilePage }; @@ -165,8 +144,7 @@ public partial class LAvenirAppPage : UserControl async void PushDishDetail(string name, string price, string description, string imageFile) { - if (_navPage == null) - return; + if (_navPage == null) return; var detail = new ContentPage { @@ -175,19 +153,22 @@ public partial class LAvenirAppPage : UserControl Header = name, }; NavigationPage.SetBottomCommandBar(detail, BuildFloatingBar(price)); - detail.Navigating += args => - { - if (args.NavigationType == NavigationType.Pop) - ApplyRootNavigationBarAppearance(); - return Task.CompletedTask; + _navPage.Background = new SolidColorBrush(BgDark); + _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgDark); + _navPage.Resources["NavigationBarForeground"] = Brushes.White; + + detail.NavigatedFrom += (_, _) => + { + if (_navPage != null) + { + _navPage.Background = new SolidColorBrush(BgLight); + _navPage.Resources["NavigationBarBackground"] = new SolidColorBrush(BgLight); + _navPage.Resources["NavigationBarForeground"] = new SolidColorBrush(TextDark); + } }; - ApplyDetailNavigationBarAppearance(); await _navPage.PushAsync(detail); - - if (!ReferenceEquals(_navPage.CurrentPage, detail)) - ApplyRootNavigationBarAppearance(); } Border BuildFloatingBar(string price) diff --git a/samples/ControlCatalog/Pages/NavigationPage/PulseAppPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/PulseAppPage.xaml.cs index f50d40f151..33e89b9b65 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/PulseAppPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/PulseAppPage.xaml.cs @@ -99,7 +99,7 @@ public partial class PulseAppPage : UserControl Content = homeView, Background = new SolidColorBrush(BgDashboard), Header = "Home", - Icon = Geometry.Parse("M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"), + Icon = new PathIcon { Data = Geometry.Parse("M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z") }, }; var workoutsPage = new ContentPage @@ -107,7 +107,7 @@ public partial class PulseAppPage : UserControl Content = new PulseWorkoutsView(), Background = new SolidColorBrush(BgDashboard), Header = "Workouts", - Icon = Geometry.Parse("M20.57 14.86L22 13.43 20.57 12 17 15.57 8.43 7 12 3.43 10.57 2 9.14 3.43 7.71 2 5.57 4.14 4.14 2.71 2.71 4.14l1.43 1.43L2 7.71l1.43 1.43L2 10.57 3.43 12 7 8.43 15.57 17 12 20.57 13.43 22l1.43-1.43L16.29 22l2.14-2.14 1.43 1.43 1.43-1.43-1.43-1.43L22 16.29z"), + Icon = new PathIcon { Data = Geometry.Parse("M20.57 14.86L22 13.43 20.57 12 17 15.57 8.43 7 12 3.43 10.57 2 9.14 3.43 7.71 2 5.57 4.14 4.14 2.71 2.71 4.14l1.43 1.43L2 7.71l1.43 1.43L2 10.57 3.43 12 7 8.43 15.57 17 12 20.57 13.43 22l1.43-1.43L16.29 22l2.14-2.14 1.43 1.43 1.43-1.43-1.43-1.43L22 16.29z") }, }; var profilePage = new ContentPage @@ -115,7 +115,7 @@ public partial class PulseAppPage : UserControl Content = new PulseProfileView(), Background = new SolidColorBrush(BgDashboard), Header = "Profile", - Icon = Geometry.Parse("M12 2C9.243 2 7 4.243 7 7s2.243 5 5 5 5-2.243 5-5-2.243-5-5-5zM12 14c-5.523 0-10 3.582-10 8a1 1 0 001 1h18a1 1 0 001-1c0-4.418-4.477-8-10-8z"), + Icon = new PathIcon { Data = Geometry.Parse("M12 2C9.243 2 7 4.243 7 7s2.243 5 5 5 5-2.243 5-5-2.243-5-5-5zM12 14c-5.523 0-10 3.582-10 8a1 1 0 001 1h18a1 1 0 001-1c0-4.418-4.477-8-10-8z") }, }; tp.Pages = new ObservableCollection { homePage, workoutsPage, profilePage }; diff --git a/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml.cs index 25091493ea..6e194cf1a5 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/RetroGamingAppPage.xaml.cs @@ -51,30 +51,11 @@ public partial class RetroGamingAppPage : UserControl _infoPanel.IsVisible = Bounds.Width >= 650; } - void ApplyHomeNavigationBarAppearance() - { - if (_nav == null) - return; - - _nav.Resources["NavigationBarBackground"] = new SolidColorBrush(SurfaceColor); - _nav.Resources["NavigationBarForeground"] = new SolidColorBrush(CyanColor); - } - - void ApplyDetailNavigationBarAppearance() - { - if (_nav == null) - return; - - _nav.Resources["NavigationBarBackground"] = Brushes.Transparent; - _nav.Resources["NavigationBarForeground"] = new SolidColorBrush(CyanColor); - } - ContentPage BuildHomePage() { var page = new ContentPage { Background = new SolidColorBrush(BgColor) }; page.Header = BuildPixelArcadeLogo(); NavigationPage.SetTopCommandBar(page, BuildNavBarRight()); - ApplyHomeNavigationBarAppearance(); var panel = new Panel(); panel.Children.Add(BuildHomeTabbedPage()); @@ -193,7 +174,7 @@ public partial class RetroGamingAppPage : UserControl var homeTab = new ContentPage { Header = "Home", - Icon = Geometry.Parse("M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z"), + Icon = new PathIcon { Data = Geometry.Parse("M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z") }, Background = new SolidColorBrush(BgColor), Content = homeView, }; @@ -204,7 +185,7 @@ public partial class RetroGamingAppPage : UserControl var gamesTab = new ContentPage { Header = "Games", - Icon = Geometry.Parse("M7.97,16L5,19C4.67,19.3 4.23,19.5 3.75,19.5A1.75,1.75 0 0,1 2,17.75V17.5L3,10.12C3.21,7.81 5.14,6 7.5,6H16.5C18.86,6 20.79,7.81 21,10.12L22,17.5V17.75A1.75,1.75 0 0,1 20.25,19.5C19.77,19.5 19.33,19.3 19,19L16.03,16H7.97M7,9V11H5V13H7V15H9V13H11V11H9V9H7M14.5,12A1.5,1.5 0 0,0 13,13.5A1.5,1.5 0 0,0 14.5,15A1.5,1.5 0 0,0 16,13.5A1.5,1.5 0 0,0 14.5,12M17.5,9A1.5,1.5 0 0,0 16,10.5A1.5,1.5 0 0,0 17.5,12A1.5,1.5 0 0,0 19,10.5A1.5,1.5 0 0,0 17.5,9Z"), + Icon = new PathIcon { Data = Geometry.Parse("M7.97,16L5,19C4.67,19.3 4.23,19.5 3.75,19.5A1.75,1.75 0 0,1 2,17.75V17.5L3,10.12C3.21,7.81 5.14,6 7.5,6H16.5C18.86,6 20.79,7.81 21,10.12L22,17.5V17.75A1.75,1.75 0 0,1 20.25,19.5C19.77,19.5 19.33,19.3 19,19L16.03,16H7.97M7,9V11H5V13H7V15H9V13H11V11H9V9H7M14.5,12A1.5,1.5 0 0,0 13,13.5A1.5,1.5 0 0,0 14.5,15A1.5,1.5 0 0,0 16,13.5A1.5,1.5 0 0,0 14.5,12M17.5,9A1.5,1.5 0 0,0 16,10.5A1.5,1.5 0 0,0 17.5,12A1.5,1.5 0 0,0 19,10.5A1.5,1.5 0 0,0 17.5,9Z") }, Background = new SolidColorBrush(BgColor), Content = gamesView, }; @@ -212,7 +193,7 @@ public partial class RetroGamingAppPage : UserControl var favTab = new ContentPage { Header = "Favorites", - Icon = Geometry.Parse("M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"), + Icon = new PathIcon { Data = Geometry.Parse("M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z") }, Background = new SolidColorBrush(BgColor), Content = new RetroGamingFavoritesView(), }; @@ -220,7 +201,7 @@ public partial class RetroGamingAppPage : UserControl var profileTab = new ContentPage { Header = "Profile", - Icon = Geometry.Parse("M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z"), + Icon = new PathIcon { Data = Geometry.Parse("M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z") }, Background = new SolidColorBrush(BgColor), Content = new RetroGamingProfileView(), }; @@ -279,8 +260,7 @@ public partial class RetroGamingAppPage : UserControl async void PushDetailPage(string gameTitle) { - if (_nav == null) - return; + if (_nav == null) return; var detailView = new RetroGamingDetailView(gameTitle); @@ -291,13 +271,8 @@ public partial class RetroGamingAppPage : UserControl }; NavigationPage.SetBarLayoutBehavior(page, BarLayoutBehavior.Overlay); - page.Navigating += args => - { - if (args.NavigationType == NavigationType.Pop) - ApplyHomeNavigationBarAppearance(); - - return Task.CompletedTask; - }; + page.NavigatedTo += (_, _) => { if (_nav != null) _nav.Resources["NavigationBarBackground"] = Brushes.Transparent; }; + page.NavigatedFrom += (_, _) => { if (_nav != null) _nav.Resources["NavigationBarBackground"] = new SolidColorBrush(SurfaceColor); }; var cmdBar = new StackPanel { @@ -326,10 +301,6 @@ public partial class RetroGamingAppPage : UserControl cmdBar.Children.Add(shareBtn); NavigationPage.SetTopCommandBar(page, cmdBar); - ApplyDetailNavigationBarAppearance(); await _nav.PushAsync(page); - - if (!ReferenceEquals(_nav.CurrentPage, page)) - ApplyHomeNavigationBarAppearance(); } } diff --git a/src/Avalonia.Controls/TabItem.cs b/src/Avalonia.Controls/TabItem.cs index 6a116b1e28..5dc8aff6fa 100644 --- a/src/Avalonia.Controls/TabItem.cs +++ b/src/Avalonia.Controls/TabItem.cs @@ -146,6 +146,25 @@ namespace Avalonia.Controls protected bool UpdateSelectionFromEvent(RoutedEventArgs e) => SelectingItemsControl.ItemsControlFromItemContainer(this)?.UpdateSelectionFromEvent(this, e) ?? false; + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == ForegroundProperty || change.Property == IconProperty) + UpdateIconForeground(); + } + + private void UpdateIconForeground() + { + if (Icon is IconElement icon) + { + var fg = Foreground; + if (fg != null) + icon.SetValue(ForegroundProperty, fg); + else + icon.ClearValue(ForegroundProperty); + } + } + private void UpdateHeader(AvaloniaPropertyChangedEventArgs obj) { if (Header == null) From 099b95644be5a83e897b3a168eab2ae00304c22e Mon Sep 17 00:00:00 2001 From: Mike James Date: Sun, 29 Mar 2026 16:39:51 +0100 Subject: [PATCH 29/55] Update PULL_REQUEST_TEMPLATE.md Updated who is on the core team. --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a1d2625a47..6e727b7cde 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,7 +26,7 @@ ## Obsoletions / Deprecations - + ## Fixed issues @@ -61,6 +61,18 @@ baseline/Avalonia.Skia/lib/net10.0/Avalonia.Skia.dll current/Avalonia.Skia/lib/net10.0/Avalonia.Skia.dll + + CP0002 + M:Avalonia.Skia.ISkiaGpuRenderTarget.get_IsCorrupted + baseline/Avalonia.Skia/lib/net10.0/Avalonia.Skia.dll + current/Avalonia.Skia/lib/net10.0/Avalonia.Skia.dll + + + CP0002 + M:Avalonia.Skia.ISkiaGpuRenderTarget.get_IsReady + baseline/Avalonia.Skia/lib/net10.0/Avalonia.Skia.dll + current/Avalonia.Skia/lib/net10.0/Avalonia.Skia.dll + CP0002 M:Avalonia.Skia.Helpers.DrawingContextHelper.WrapSkiaCanvas(SkiaSharp.SKCanvas,Avalonia.Vector) @@ -85,6 +97,18 @@ baseline/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll current/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll + + CP0002 + M:Avalonia.Skia.ISkiaGpuRenderTarget.get_IsCorrupted + baseline/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll + current/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll + + + CP0002 + M:Avalonia.Skia.ISkiaGpuRenderTarget.get_IsReady + baseline/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll + current/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll + CP0006 M:Avalonia.Skia.ISkiaGpu.TryCreateRenderTarget(System.Collections.Generic.IEnumerable{Avalonia.Platform.Surfaces.IPlatformRenderSurface}) @@ -169,4 +193,4 @@ baseline/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll current/Avalonia.Skia/lib/net8.0/Avalonia.Skia.dll - + \ No newline at end of file diff --git a/api/Avalonia.Win32.nupkg.xml b/api/Avalonia.Win32.nupkg.xml new file mode 100644 index 0000000000..903595add8 --- /dev/null +++ b/api/Avalonia.Win32.nupkg.xml @@ -0,0 +1,16 @@ + + + + + CP0002 + M:Avalonia.Win32.DirectX.IDirect3D11TextureRenderTarget.get_IsCorrupted + baseline/Avalonia.Win32/lib/net10.0/Avalonia.Win32.dll + current/Avalonia.Win32/lib/net10.0/Avalonia.Win32.dll + + + CP0002 + M:Avalonia.Win32.DirectX.IDirect3D11TextureRenderTarget.get_IsCorrupted + baseline/Avalonia.Win32/lib/net8.0/Avalonia.Win32.dll + current/Avalonia.Win32/lib/net8.0/Avalonia.Win32.dll + + \ No newline at end of file diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 71bdf3714d..f3a9929ca1 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1693,12 +1693,30 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Platform.IRenderTarget.get_IsCorrupted + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IRenderTarget.get_IsReady + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Platform.LockedFramebuffer.#ctor(System.IntPtr,Avalonia.PixelSize,System.Int32,Avalonia.Vector,Avalonia.Platform.PixelFormat,System.Action) baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Platform.Surfaces.IPlatformRenderSurfaceRenderTarget.get_IsReady + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Rendering.Composition.ICompositionGpuImportedObject.get_ImportCompeted @@ -2587,6 +2605,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.OpenGL.dll current/Avalonia/lib/net10.0/Avalonia.OpenGL.dll + + CP0002 + M:Avalonia.OpenGL.Surfaces.IGlPlatformSurfaceRenderTarget.get_IsCorrupted + baseline/Avalonia/lib/net10.0/Avalonia.OpenGL.dll + current/Avalonia/lib/net10.0/Avalonia.OpenGL.dll + CP0002 M:Avalonia.Vulkan.IVulkanKhrSurfacePlatformSurfaceFactory.CanRenderToSurface(Avalonia.Vulkan.IVulkanPlatformGraphicsContext,System.Object) @@ -3367,12 +3391,30 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Platform.IRenderTarget.get_IsCorrupted + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IRenderTarget.get_IsReady + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Platform.LockedFramebuffer.#ctor(System.IntPtr,Avalonia.PixelSize,System.Int32,Avalonia.Vector,Avalonia.Platform.PixelFormat,System.Action) baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Platform.Surfaces.IPlatformRenderSurfaceRenderTarget.get_IsReady + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Rendering.Composition.ICompositionGpuImportedObject.get_ImportCompeted @@ -4267,6 +4309,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.OpenGL.dll current/Avalonia/lib/net8.0/Avalonia.OpenGL.dll + + CP0002 + M:Avalonia.OpenGL.Surfaces.IGlPlatformSurfaceRenderTarget.get_IsCorrupted + baseline/Avalonia/lib/net8.0/Avalonia.OpenGL.dll + current/Avalonia/lib/net8.0/Avalonia.OpenGL.dll + CP0002 M:Avalonia.Vulkan.IVulkanKhrSurfacePlatformSurfaceFactory.CanRenderToSurface(Avalonia.Vulkan.IVulkanPlatformGraphicsContext,System.Object) diff --git a/src/Avalonia.Base/Platform/IRenderTarget.cs b/src/Avalonia.Base/Platform/IRenderTarget.cs index e66d14995e..2e7d56405a 100644 --- a/src/Avalonia.Base/Platform/IRenderTarget.cs +++ b/src/Avalonia.Base/Platform/IRenderTarget.cs @@ -12,11 +12,6 @@ namespace Avalonia.Platform [PrivateApi] public interface IRenderTarget : IDisposable { - /// - /// Indicates if the render target is no longer usable and needs to be recreated - /// - bool IsCorrupted { get; } - /// /// Gets the properties of the render target. /// @@ -33,9 +28,9 @@ namespace Avalonia.Platform IDrawingContextImpl CreateDrawingContext(RenderTargetSceneInfo sceneInfo, out RenderTargetDrawingContextProperties properties); /// - /// Indicates if the render target is currently ready to be rendered to + /// Gets the current readiness state of the render target. /// - bool IsReady => true; + PlatformRenderTargetState PlatformRenderTargetState => PlatformRenderTargetState.Ready; public record struct RenderTargetSceneInfo(PixelSize Size, double Scaling); } diff --git a/src/Avalonia.Base/Platform/RenderTargetProperties.cs b/src/Avalonia.Base/Platform/RenderTargetProperties.cs index c4a0948180..de4f3f4345 100644 --- a/src/Avalonia.Base/Platform/RenderTargetProperties.cs +++ b/src/Avalonia.Base/Platform/RenderTargetProperties.cs @@ -3,6 +3,36 @@ using Avalonia.Metadata; namespace Avalonia.Platform; +/// +/// Describes the current readiness state of a platform render target. +/// Flows through the entire rendering pipeline from platform to compositor. +/// +[PrivateApi] +[SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "Private API, not meant to be compared")] +public readonly struct PlatformRenderTargetState +{ + /// + /// Indicates if the render target is currently ready to be rendered to. + /// + public bool IsReady { get; init; } + + /// + /// Indicates if the render target is no longer usable and needs to be recreated + /// + public bool IsCorrupted { get; init; } + + /// + /// A readiness state indicating the target is ready to render. + /// + public static PlatformRenderTargetState Ready => new() { IsReady = true }; + + public static PlatformRenderTargetState NotReadyTryLater => default; + + public static PlatformRenderTargetState Corrupted => new() { IsCorrupted = true, IsReady = true}; + + public static PlatformRenderTargetState Disposed => new() { IsCorrupted = true}; +} + [PrivateApi] [SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "Private API, not meant to be compared")] public struct RenderTargetProperties diff --git a/src/Avalonia.Base/Platform/Surfaces/IPlatformRenderSurface.cs b/src/Avalonia.Base/Platform/Surfaces/IPlatformRenderSurface.cs index ff71a700c4..0c085c20b8 100644 --- a/src/Avalonia.Base/Platform/Surfaces/IPlatformRenderSurface.cs +++ b/src/Avalonia.Base/Platform/Surfaces/IPlatformRenderSurface.cs @@ -11,5 +11,5 @@ public interface IPlatformRenderSurface [PrivateApi] public interface IPlatformRenderSurfaceRenderTarget { - bool IsReady => true; + PlatformRenderTargetState State => PlatformRenderTargetState.Ready; } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs index e8ae84eb03..fe857ac466 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionTarget.cs @@ -138,7 +138,7 @@ namespace Avalonia.Rendering.Composition.Server if (Root == null) return; - if (_renderTarget?.IsCorrupted == true) + if (_renderTarget?.PlatformRenderTargetState.IsCorrupted == true) { _layer?.Dispose(); _layer = null; @@ -178,7 +178,7 @@ namespace Avalonia.Rendering.Composition.Server if (!_redrawRequested) return; - if (!_renderTarget.IsReady) + if (!_renderTarget.PlatformRenderTargetState.IsReady) { IsWaitingForReadyRenderTarget = IsEnabled; return; diff --git a/src/Avalonia.Native/AvaloniaNativeGlPlatformGraphics.cs b/src/Avalonia.Native/AvaloniaNativeGlPlatformGraphics.cs index 91e4dd2680..9efe33689f 100644 --- a/src/Avalonia.Native/AvaloniaNativeGlPlatformGraphics.cs +++ b/src/Avalonia.Native/AvaloniaNativeGlPlatformGraphics.cs @@ -173,7 +173,7 @@ namespace Avalonia.Native _context = context; } - public bool IsCorrupted => false; + public PlatformRenderTargetState State => PlatformRenderTargetState.Ready; public IGlPlatformSurfaceRenderingSession BeginDraw(IRenderTarget.RenderTargetSceneInfo sceneInfo) { diff --git a/src/Avalonia.OpenGL/Egl/EglGlPlatformSurfaceBase.cs b/src/Avalonia.OpenGL/Egl/EglGlPlatformSurfaceBase.cs index ef4ae257bd..8ee06266d8 100644 --- a/src/Avalonia.OpenGL/Egl/EglGlPlatformSurfaceBase.cs +++ b/src/Avalonia.OpenGL/Egl/EglGlPlatformSurfaceBase.cs @@ -110,6 +110,9 @@ namespace Avalonia.OpenGL.Egl public bool IsYFlipped { get; } } + public virtual PlatformRenderTargetState State => + IsCorrupted ? PlatformRenderTargetState.Corrupted : PlatformRenderTargetState.Ready; + public virtual bool IsCorrupted => Context.IsLost; } } diff --git a/src/Avalonia.OpenGL/Surfaces/IGlPlatformSurfaceRenderTarget.cs b/src/Avalonia.OpenGL/Surfaces/IGlPlatformSurfaceRenderTarget.cs index da35de6649..86c733c5fa 100644 --- a/src/Avalonia.OpenGL/Surfaces/IGlPlatformSurfaceRenderTarget.cs +++ b/src/Avalonia.OpenGL/Surfaces/IGlPlatformSurfaceRenderTarget.cs @@ -8,7 +8,6 @@ namespace Avalonia.OpenGL.Surfaces [PrivateApi] public interface IGlPlatformSurfaceRenderTarget : IDisposable, IPlatformRenderSurfaceRenderTarget { - bool IsCorrupted { get; } IGlPlatformSurfaceRenderingSession BeginDraw(IRenderTarget.RenderTargetSceneInfo sceneInfo); } } diff --git a/src/Avalonia.Vulkan/VulkanKhrSurfaceRenderTarget.cs b/src/Avalonia.Vulkan/VulkanKhrSurfaceRenderTarget.cs index a24b9eea7a..64aaa5ad30 100644 --- a/src/Avalonia.Vulkan/VulkanKhrSurfaceRenderTarget.cs +++ b/src/Avalonia.Vulkan/VulkanKhrSurfaceRenderTarget.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Platform; using Avalonia.Vulkan.Interop; using Avalonia.Vulkan.UnmanagedInterop; @@ -26,6 +27,8 @@ internal class VulkanKhrRenderTarget : IVulkanRenderTarget Format = IsRgba ? VkFormat.VK_FORMAT_R8G8B8A8_UNORM : VkFormat.VK_FORMAT_B8G8R8A8_UNORM; } + public PlatformRenderTargetState State => PlatformRenderTargetState.Ready; + private void CreateImage() { _image = new VulkanImage(_context, _display.CommandBufferPool, Format, _display.Size); diff --git a/src/Avalonia.X11/Glx/GlxGlPlatformSurface.cs b/src/Avalonia.X11/Glx/GlxGlPlatformSurface.cs index 81d7a4e9bc..b91c7ac6c9 100644 --- a/src/Avalonia.X11/Glx/GlxGlPlatformSurface.cs +++ b/src/Avalonia.X11/Glx/GlxGlPlatformSurface.cs @@ -39,7 +39,7 @@ namespace Avalonia.X11.Glx // No-op } - public bool IsCorrupted => false; + public PlatformRenderTargetState State => PlatformRenderTargetState.Ready; public IGlPlatformSurfaceRenderingSession BeginDraw(IRenderTarget.RenderTargetSceneInfo sceneInfo) { var size = sceneInfo.Size; diff --git a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs index b83cf8b526..1c152d0fe4 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -608,8 +608,6 @@ namespace Avalonia.Headless properties = default; return new HeadlessDrawingContextStub(); } - - public bool IsCorrupted => false; } public void Dispose() diff --git a/src/Linux/Avalonia.LinuxFramebuffer/Output/DrmOutput.cs b/src/Linux/Avalonia.LinuxFramebuffer/Output/DrmOutput.cs index cf0bf1e49a..dabf1b6bfd 100644 --- a/src/Linux/Avalonia.LinuxFramebuffer/Output/DrmOutput.cs +++ b/src/Linux/Avalonia.LinuxFramebuffer/Output/DrmOutput.cs @@ -407,7 +407,7 @@ namespace Avalonia.LinuxFramebuffer.Output _parent = parent; } - public bool IsCorrupted => false; + public PlatformRenderTargetState State => PlatformRenderTargetState.Ready; public void Dispose() { diff --git a/src/Skia/Avalonia.Skia/FramebufferRenderTarget.cs b/src/Skia/Avalonia.Skia/FramebufferRenderTarget.cs index 5491df93b8..828de2843f 100644 --- a/src/Skia/Avalonia.Skia/FramebufferRenderTarget.cs +++ b/src/Skia/Avalonia.Skia/FramebufferRenderTarget.cs @@ -47,8 +47,8 @@ namespace Avalonia.Skia IsSuitableForDirectRendering = true }; - - + public PlatformRenderTargetState PlatformRenderTargetState => + _renderTarget?.State ?? PlatformRenderTargetState.Disposed; /// public IDrawingContextImpl CreateDrawingContext(IRenderTarget.RenderTargetSceneInfo sceneInfo, @@ -86,10 +86,7 @@ namespace Avalonia.Skia return new DrawingContextImpl(createInfo, _preFramebufferCopyHandler, canvas, framebuffer); } - - public bool IsCorrupted => false; - - public bool IsReady => _renderTarget is not IPlatformRenderSurfaceRenderTarget prs || prs.IsReady; + /// /// Check if two images info are compatible. diff --git a/src/Skia/Avalonia.Skia/Gpu/ISkiaGpuRenderTarget.cs b/src/Skia/Avalonia.Skia/Gpu/ISkiaGpuRenderTarget.cs index 65b6b5a8df..bd8061cf98 100644 --- a/src/Skia/Avalonia.Skia/Gpu/ISkiaGpuRenderTarget.cs +++ b/src/Skia/Avalonia.Skia/Gpu/ISkiaGpuRenderTarget.cs @@ -15,8 +15,6 @@ namespace Avalonia.Skia /// A render session instance. ISkiaGpuRenderSession BeginRenderingSession(IRenderTarget.RenderTargetSceneInfo sceneInfo); - bool IsCorrupted { get; } - - bool IsReady => true; + PlatformRenderTargetState State => PlatformRenderTargetState.Ready; } } diff --git a/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs b/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs index 640b415d6f..402d83fd10 100644 --- a/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs +++ b/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs @@ -108,9 +108,7 @@ internal class SkiaMetalGpu : ISkiaGpu return new SkiaMetalRenderSession(_gpu, surface, session, backendTarget); } - public bool IsCorrupted => false; - - public bool IsReady => _target?.IsReady ?? false; + public PlatformRenderTargetState State => _target?.State ?? PlatformRenderTargetState.Disposed; } internal class SkiaMetalRenderSession : ISkiaGpuRenderSession diff --git a/src/Skia/Avalonia.Skia/Gpu/OpenGl/GlRenderTarget.cs b/src/Skia/Avalonia.Skia/Gpu/OpenGl/GlRenderTarget.cs index f5c164be45..69e2f0563e 100644 --- a/src/Skia/Avalonia.Skia/Gpu/OpenGl/GlRenderTarget.cs +++ b/src/Skia/Avalonia.Skia/Gpu/OpenGl/GlRenderTarget.cs @@ -21,9 +21,7 @@ namespace Avalonia.Skia public void Dispose() => _surface.Dispose(); - public bool IsCorrupted => _surface.IsCorrupted; - - public bool IsReady => _surface.IsReady; + public PlatformRenderTargetState State => _surface.State; class GlGpuSession : ISkiaGpuRenderSession { diff --git a/src/Skia/Avalonia.Skia/Gpu/SkiaGpuRenderTarget.cs b/src/Skia/Avalonia.Skia/Gpu/SkiaGpuRenderTarget.cs index 13bd946b74..d748018824 100644 --- a/src/Skia/Avalonia.Skia/Gpu/SkiaGpuRenderTarget.cs +++ b/src/Skia/Avalonia.Skia/Gpu/SkiaGpuRenderTarget.cs @@ -43,9 +43,8 @@ namespace Avalonia.Skia return new DrawingContextImpl(nfo, session); } - - public bool IsCorrupted => _renderTarget.IsCorrupted; - public bool IsReady => _renderTarget.IsReady; + + public PlatformRenderTargetState PlatformRenderTargetState => _renderTarget.State; public RenderTargetProperties Properties { get; } diff --git a/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaRenderTarget.cs b/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaRenderTarget.cs index 6b04fcd8c0..dc229e16e8 100644 --- a/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaRenderTarget.cs +++ b/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaRenderTarget.cs @@ -74,9 +74,7 @@ class VulkanSkiaRenderTarget : ISkiaGpuRenderTarget } } - public bool IsCorrupted => false; - - public bool IsReady => _target.IsReady; + public PlatformRenderTargetState State => _target.State; internal class VulkanSkiaRenderSession : ISkiaGpuRenderSession diff --git a/src/Windows/Avalonia.Win32/DComposition/DirectCompositedWindowSurface.cs b/src/Windows/Avalonia.Win32/DComposition/DirectCompositedWindowSurface.cs index d0651e08ee..9c6f5707bf 100644 --- a/src/Windows/Avalonia.Win32/DComposition/DirectCompositedWindowSurface.cs +++ b/src/Windows/Avalonia.Win32/DComposition/DirectCompositedWindowSurface.cs @@ -78,11 +78,11 @@ internal class DirectCompositedWindowRenderTarget : IDirect3D11TextureRenderTarg _d3dDevice.Dispose(); } - public bool IsCorrupted => _context.IsLost || _lost; - + public PlatformRenderTargetState State => _context.IsLost || _lost ? PlatformRenderTargetState.Corrupted : PlatformRenderTargetState.Ready; + public unsafe IDirect3D11TextureRenderTargetRenderSession BeginDraw() { - if (IsCorrupted) + if (State.IsCorrupted) throw new RenderTargetCorruptedException(); var transaction = _window.BeginTransaction(); bool needsEndDraw = false; diff --git a/src/Windows/Avalonia.Win32/DirectX/IDirect3D11TexturePlatformSurface.cs b/src/Windows/Avalonia.Win32/DirectX/IDirect3D11TexturePlatformSurface.cs index fb1a6a27e7..ce077d6ce0 100644 --- a/src/Windows/Avalonia.Win32/DirectX/IDirect3D11TexturePlatformSurface.cs +++ b/src/Windows/Avalonia.Win32/DirectX/IDirect3D11TexturePlatformSurface.cs @@ -1,5 +1,6 @@ using System; using Avalonia.OpenGL; +using Avalonia.OpenGL.Surfaces; using Avalonia.Platform; using Avalonia.Platform.Surfaces; @@ -12,9 +13,8 @@ public interface IDirect3D11TexturePlatformSurface : IPlatformRenderSurface -public interface IDirect3D11TextureRenderTarget : IDisposable +public interface IDirect3D11TextureRenderTarget : IPlatformRenderSurfaceRenderTarget, IDisposable { - bool IsCorrupted { get; } IDirect3D11TextureRenderTargetRenderSession BeginDraw(); } diff --git a/src/Windows/Avalonia.Win32/OpenGl/Angle/AngleD3DTextureFeature.cs b/src/Windows/Avalonia.Win32/OpenGl/Angle/AngleD3DTextureFeature.cs index 0dc8ec453e..60882fb62e 100644 --- a/src/Windows/Avalonia.Win32/OpenGl/Angle/AngleD3DTextureFeature.cs +++ b/src/Windows/Avalonia.Win32/OpenGl/Angle/AngleD3DTextureFeature.cs @@ -83,7 +83,8 @@ internal class AngleD3DTextureFeature : IGlPlatformSurfaceRenderTargetFactory base.Dispose(); } - public override bool IsCorrupted => _target.IsCorrupted || base.IsCorrupted; + public override PlatformRenderTargetState State => + base.IsCorrupted ? PlatformRenderTargetState.Corrupted : _target.State; } public IGlPlatformSurfaceRenderTarget CreateRenderTarget(IGlContext context, IPlatformRenderSurface surface) diff --git a/src/Windows/Avalonia.Win32/OpenGl/WglGlPlatformSurface.cs b/src/Windows/Avalonia.Win32/OpenGl/WglGlPlatformSurface.cs index f54ca9b62c..39a7da74ca 100644 --- a/src/Windows/Avalonia.Win32/OpenGl/WglGlPlatformSurface.cs +++ b/src/Windows/Avalonia.Win32/OpenGl/WglGlPlatformSurface.cs @@ -37,7 +37,7 @@ namespace Avalonia.Win32.OpenGl _hdc = context.CreateConfiguredDeviceContext(info.Handle); } - public bool IsCorrupted => false; + public PlatformRenderTargetState State => PlatformRenderTargetState.Ready; public void Dispose() { diff --git a/src/Windows/Avalonia.Win32/WinRT/Composition/WinUiCompositedWindowSurface.cs b/src/Windows/Avalonia.Win32/WinRT/Composition/WinUiCompositedWindowSurface.cs index 2addbc6524..96e5fae00b 100644 --- a/src/Windows/Avalonia.Win32/WinRT/Composition/WinUiCompositedWindowSurface.cs +++ b/src/Windows/Avalonia.Win32/WinRT/Composition/WinUiCompositedWindowSurface.cs @@ -114,11 +114,12 @@ namespace Avalonia.Win32.WinRT.Composition _d3dDevice.Dispose(); } - public bool IsCorrupted => _context.IsLost || _lost; + public PlatformRenderTargetState State => + _context.IsLost || _lost ? PlatformRenderTargetState.Corrupted : PlatformRenderTargetState.Ready; public unsafe IDirect3D11TextureRenderTargetRenderSession BeginDraw() { - if (IsCorrupted) + if (State.IsCorrupted) throw new RenderTargetCorruptedException(); var transaction = _window.BeginTransaction(); bool needsEndDraw = false; From b2ee6119fde635c2ca9cf179162391e9afebb4af Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 30 Mar 2026 14:26:35 +0500 Subject: [PATCH 34/55] Change XInternAtoms call force-create atoms (#21034) --- src/tools/DevGenerators/X11AtomsGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/DevGenerators/X11AtomsGenerator.cs b/src/tools/DevGenerators/X11AtomsGenerator.cs index 920b3477dc..e91016af3e 100644 --- a/src/tools/DevGenerators/X11AtomsGenerator.cs +++ b/src/tools/DevGenerators/X11AtomsGenerator.cs @@ -72,7 +72,7 @@ public class X11AtomsGenerator : IIncrementalGenerator classBuilder.Pad(3).Append("\"").Append(writeableFields[c].Name).AppendLine("\","); classBuilder.Pad(2).AppendLine("};"); - classBuilder.Pad(2).AppendLine("XInternAtoms(display, atomNames, atomNames.Length, true, atoms);"); + classBuilder.Pad(2).AppendLine("XInternAtoms(display, atomNames, atomNames.Length, false, atoms);"); for (int c = 0; c < writeableFields.Count; c++) classBuilder.Pad(2).Append("InitAtom(ref ").Append(writeableFields[c].Name).Append(", \"") From 94c809f84eef0dbb08f84ce11ddbee3cffdd93dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Mon, 30 Mar 2026 16:18:25 +0200 Subject: [PATCH 35/55] Rename AppBar controls (#21041) * Renamed AppBar buttons and separator * CommandBarSeparator inherit from Separator * Added suppressions --- api/Avalonia.nupkg.xml | 36 ++++ .../CommandBarCustomizationPage.xaml | 24 +-- .../CommandBarDynamicOverflowPage.xaml | 12 +- .../CommandBar/CommandBarEventsPage.xaml | 10 +- .../CommandBar/CommandBarEventsPage.xaml.cs | 18 +- .../CommandBar/CommandBarFirstLookPage.xaml | 56 +++--- .../CommandBarFirstLookPage.xaml.cs | 4 +- .../CommandBarLabelPositionPage.xaml | 24 +-- .../CommandBar/CommandBarOverflowPage.xaml | 10 +- .../CommandBar/CommandBarOverflowPage.xaml.cs | 4 +- .../CommandBar/CommandBarTogglePage.xaml | 28 +-- .../Pages/CommandBarPage.xaml.cs | 2 +- .../ContentPageCommandBarPage.xaml.cs | 20 +- .../NavigationPageToolbarPage.xaml.cs | 16 +- .../CommandBar/CommandBar.cs | 12 +- .../{AppBarButton.cs => CommandBarButton.cs} | 20 +- ...BarSeparator.cs => CommandBarSeparator.cs} | 8 +- ...gleButton.cs => CommandBarToggleButton.cs} | 20 +- .../Controls/CommandBar.xaml | 38 ++-- .../Controls/NavigationPage.xaml | 4 +- .../Controls/CommandBar.xaml | 28 +-- .../CommandBarTests.cs | 182 +++++++++--------- .../Controls/CommandBarTests.cs | 16 +- .../Controls/ContentPageTests.cs | 8 +- 24 files changed, 319 insertions(+), 281 deletions(-) rename src/Avalonia.Controls/CommandBar/{AppBarButton.cs => CommandBarButton.cs} (80%) rename src/Avalonia.Controls/CommandBar/{AppBarSeparator.cs => CommandBarSeparator.cs} (81%) rename src/Avalonia.Controls/CommandBar/{AppBarToggleButton.cs => CommandBarToggleButton.cs} (79%) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index f3a9929ca1..a63ac3baae 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -5821,4 +5821,40 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0001 + T:Avalonia.Controls.AppBarButton + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0001 + T:Avalonia.Controls.AppBarSeparator + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0001 + T:Avalonia.Controls.AppBarToggleButton + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0001 + T:Avalonia.Controls.AppBarButton + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0001 + T:Avalonia.Controls.AppBarSeparator + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0001 + T:Avalonia.Controls.AppBarToggleButton + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml index 1ea3349129..3ce911d9f8 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarCustomizationPage.xaml @@ -15,9 +15,9 @@ - - - + + + @@ -74,7 +74,7 @@ - @@ -89,20 +89,20 @@ - - + + - - + + - - + + @@ -111,8 +111,8 @@ BorderBrush="#CCCCCC" BorderThickness="1" OverflowButtonVisibility="Collapsed"> - - + + diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarDynamicOverflowPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarDynamicOverflowPage.xaml index 2f771ab42e..6d66c19c96 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarDynamicOverflowPage.xaml +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarDynamicOverflowPage.xaml @@ -61,12 +61,12 @@ ClipToBounds="True"> - - - - - - + + + + + + diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml index 8dbc44e19b..b4728aa918 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml @@ -64,13 +64,13 @@ - - - + + + - - + + diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml.cs b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml.cs index c2f0b439f0..f82fcbddb9 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarEventsPage.xaml.cs @@ -141,13 +141,13 @@ namespace ControlCatalog.Pages private void OnCommandItemClick(object? sender, RoutedEventArgs e) { - if (sender is AppBarButton button) + if (sender is CommandBarButton button) AppendLog($"Click, {button.Label}, {DescribePlacement(button)}"); } - private AppBarButton CreateButton(string label) + private CommandBarButton CreateButton(string label) { - var button = new AppBarButton + var button = new CommandBarButton { Label = label, Icon = new PathIcon @@ -170,14 +170,14 @@ namespace ControlCatalog.Pages { foreach (var item in items) { - if (item is AppBarButton button) + if (item is CommandBarButton button) button.Click -= OnCommandItemClick; } } private void AttachItemHandler(ICommandBarElement item) { - if (item is not AppBarButton button) + if (item is not CommandBarButton button) return; button.Click -= OnCommandItemClick; @@ -191,10 +191,10 @@ namespace ControlCatalog.Pages return; var item = items[^1]; - var label = item is AppBarButton button ? button.Label ?? "(unnamed)" : item.GetType().Name; + var label = item is CommandBarButton button ? button.Label ?? "(unnamed)" : item.GetType().Name; - if (item is AppBarButton appBarButton) - appBarButton.Click -= OnCommandItemClick; + if (item is CommandBarButton commandBarButton) + commandBarButton.Click -= OnCommandItemClick; items.RemoveAt(items.Count - 1); @@ -202,7 +202,7 @@ namespace ControlCatalog.Pages RefreshState(); } - private static string DescribePlacement(AppBarButton button) + private static string DescribePlacement(CommandBarButton button) { return button.IsInOverflow ? "overflow" : "primary"; } diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarFirstLookPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarFirstLookPage.xaml index b83d1c3e57..60c23d9c64 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarFirstLookPage.xaml +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarFirstLookPage.xaml @@ -36,7 +36,7 @@ - @@ -52,35 +52,35 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarFirstLookPage.xaml.cs b/samples/ControlCatalog/Pages/CommandBar/CommandBarFirstLookPage.xaml.cs index c624ffcad6..9e15c0fb6c 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarFirstLookPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarFirstLookPage.xaml.cs @@ -12,13 +12,13 @@ namespace ControlCatalog.Pages private void OnButtonClick(object? sender, RoutedEventArgs e) { - if (sender is AppBarButton btn) + if (sender is CommandBarButton btn) StatusText.Text = $"{btn.Label} clicked"; } private void OnToggleChanged(object? sender, RoutedEventArgs e) { - if (sender is AppBarToggleButton btn) + if (sender is CommandBarToggleButton btn) StatusText.Text = btn.IsChecked == true ? $"{btn.Label} enabled" : $"{btn.Label} disabled"; diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarLabelPositionPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarLabelPositionPage.xaml index a1dbea2db1..a4b23542d7 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarLabelPositionPage.xaml +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarLabelPositionPage.xaml @@ -15,10 +15,10 @@ - - - - + + + + @@ -26,10 +26,10 @@ - - - - + + + + @@ -37,10 +37,10 @@ - - - - + + + + diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarOverflowPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarOverflowPage.xaml index be3371a8e8..46bcac3fac 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarOverflowPage.xaml +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarOverflowPage.xaml @@ -59,13 +59,13 @@ - - - + + + - - + + diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarOverflowPage.xaml.cs b/samples/ControlCatalog/Pages/CommandBar/CommandBarOverflowPage.xaml.cs index a0d5f24b7f..ff5ee07f2a 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarOverflowPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarOverflowPage.xaml.cs @@ -42,13 +42,13 @@ namespace ControlCatalog.Pages private void OnAddPrimary(object? sender, RoutedEventArgs e) { _primaryCount++; - DemoBar.PrimaryCommands.Add(new AppBarButton { Label = $"Cmd {_primaryCount}" }); + DemoBar.PrimaryCommands.Add(new CommandBarButton { Label = $"Cmd {_primaryCount}" }); } private void OnAddSecondary(object? sender, RoutedEventArgs e) { _secondaryCount++; - DemoBar.SecondaryCommands.Add(new AppBarButton { Label = $"Sec {_secondaryCount}" }); + DemoBar.SecondaryCommands.Add(new CommandBarButton { Label = $"Sec {_secondaryCount}" }); } } } diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarTogglePage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarTogglePage.xaml index d2513038c0..794bd3fcd4 100644 --- a/samples/ControlCatalog/Pages/CommandBar/CommandBarTogglePage.xaml +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarTogglePage.xaml @@ -28,7 +28,7 @@ - @@ -37,25 +37,25 @@ - AppBarToggleButton supports on/off states for formatting and feature toggles. + CommandBarToggleButton supports on/off states for formatting and feature toggles. - - - - + + - - - + + - - + + - - - + + + diff --git a/samples/ControlCatalog/Pages/CommandBarPage.xaml.cs b/samples/ControlCatalog/Pages/CommandBarPage.xaml.cs index 7aaf6d22be..e76d605645 100644 --- a/samples/ControlCatalog/Pages/CommandBarPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CommandBarPage.xaml.cs @@ -13,7 +13,7 @@ namespace ControlCatalog.Pages { // Overview ("Overview", "First Look", "A CommandBar with primary commands, secondary overflow menu, and custom content area.", () => new CommandBarFirstLookPage()), - ("Overview", "Toggle Buttons", "AppBarToggleButton for stateful actions like Bold, Italic, and Favorite.", () => new CommandBarTogglePage()), + ("Overview", "Toggle Buttons", "CommandBarToggleButton for stateful actions like Bold, Italic, and Favorite.", () => new CommandBarTogglePage()), // Appearance ("Appearance", "Label Positions", "Configure label position: Bottom (default), Right, or Collapsed (icon only).", () => new CommandBarLabelPositionPage()), diff --git a/samples/ControlCatalog/Pages/ContentPage/ContentPageCommandBarPage.xaml.cs b/samples/ControlCatalog/Pages/ContentPage/ContentPageCommandBarPage.xaml.cs index 07bdbb52a2..3bb6b30082 100644 --- a/samples/ControlCatalog/Pages/ContentPage/ContentPageCommandBarPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ContentPage/ContentPageCommandBarPage.xaml.cs @@ -78,7 +78,7 @@ namespace ControlCatalog.Pages private void OnAddPrimary(object? sender, RoutedEventArgs e) { _itemCounter++; - var btn = new AppBarButton { Label = $"Action {_itemCounter}" }; + var btn = new CommandBarButton { Label = $"Action {_itemCounter}" }; if (UseIconCheck.IsChecked == true) { var preset = IconPresets[(_itemCounter - 1) % IconPresets.Length]; @@ -93,13 +93,13 @@ namespace ControlCatalog.Pages private void OnAddSecondary(object? sender, RoutedEventArgs e) { _itemCounter++; - _secondaryItems.Add(new AppBarButton { Label = $"Item {_itemCounter}" }); + _secondaryItems.Add(new CommandBarButton { Label = $"Item {_itemCounter}" }); RebuildCommandBar(); } private void OnAddSeparator(object? sender, RoutedEventArgs e) { - _primaryItems.Add(new AppBarSeparator()); + _primaryItems.Add(new CommandBarSeparator()); RebuildCommandBar(); } @@ -177,26 +177,26 @@ namespace ControlCatalog.Pages foreach (var item in _primaryItems) { - if (item is AppBarButton btn) + if (item is CommandBarButton btn) { PathIcon? icon = null; if (btn.Icon is PathIcon src) icon = new PathIcon { Data = src.Data }; - commandBar.PrimaryCommands.Add(new AppBarButton + commandBar.PrimaryCommands.Add(new CommandBarButton { Label = btn.Label, Icon = icon, IsCompact = btn.IsCompact, }); } - else if (item is AppBarSeparator) - commandBar.PrimaryCommands.Add(new AppBarSeparator()); + else if (item is CommandBarSeparator) + commandBar.PrimaryCommands.Add(new CommandBarSeparator()); } foreach (var item in _secondaryItems) { - if (item is AppBarButton btn) - commandBar.SecondaryCommands.Add(new AppBarButton { Label = btn.Label }); + if (item is CommandBarButton btn) + commandBar.SecondaryCommands.Add(new CommandBarButton { Label = btn.Label }); } if (_position == "Top") @@ -204,7 +204,7 @@ namespace ControlCatalog.Pages else NavigationPage.SetBottomCommandBar(activePage, commandBar); - var primaryCount = _primaryItems.Count(i => i is AppBarButton); + var primaryCount = _primaryItems.Count(i => i is CommandBarButton); var secondaryCount = _secondaryItems.Count; StatusText.Text = $"{primaryCount} primary, {secondaryCount} secondary ({_position})"; } diff --git a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageToolbarPage.xaml.cs b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageToolbarPage.xaml.cs index c141f47781..724f8de84a 100644 --- a/samples/ControlCatalog/Pages/NavigationPage/NavigationPageToolbarPage.xaml.cs +++ b/samples/ControlCatalog/Pages/NavigationPage/NavigationPageToolbarPage.xaml.cs @@ -49,7 +49,7 @@ namespace ControlCatalog.Pages private void OnAddPrimary(object? sender, RoutedEventArgs e) { _itemCount++; - _rootCommandBar.PrimaryCommands.Add(new AppBarButton + _rootCommandBar.PrimaryCommands.Add(new CommandBarButton { Label = $"Item {_itemCount}", Icon = new PathIcon { Data = (Geometry)this.FindResource("AddIcon")! } @@ -60,7 +60,7 @@ namespace ControlCatalog.Pages private void OnAddSecondary(object? sender, RoutedEventArgs e) { _itemCount++; - _rootCommandBar.SecondaryCommands.Add(new AppBarButton + _rootCommandBar.SecondaryCommands.Add(new CommandBarButton { Label = $"Secondary {_itemCount}" }); @@ -69,13 +69,13 @@ namespace ControlCatalog.Pages private void OnAddPrimarySeparator(object? sender, RoutedEventArgs e) { - _rootCommandBar.PrimaryCommands.Add(new AppBarSeparator()); + _rootCommandBar.PrimaryCommands.Add(new CommandBarSeparator()); UpdateStatus(); } private void OnAddSecondarySeparator(object? sender, RoutedEventArgs e) { - _rootCommandBar.SecondaryCommands.Add(new AppBarSeparator()); + _rootCommandBar.SecondaryCommands.Add(new CommandBarSeparator()); UpdateStatus(); } @@ -125,13 +125,13 @@ namespace ControlCatalog.Pages { PrimaryCommands = { - new AppBarButton { Label = "Search", Icon = new PathIcon { Data = (Geometry)this.FindResource("SearchIcon")! } }, - new AppBarButton { Label = "Share", Icon = new PathIcon { Data = (Geometry)this.FindResource("ShareIcon")! } }, - new AppBarButton { Label = "Edit", Icon = new PathIcon { Data = (Geometry)this.FindResource("EditIcon")! } }, + new CommandBarButton { Label = "Search", Icon = new PathIcon { Data = (Geometry)this.FindResource("SearchIcon")! } }, + new CommandBarButton { Label = "Share", Icon = new PathIcon { Data = (Geometry)this.FindResource("ShareIcon")! } }, + new CommandBarButton { Label = "Edit", Icon = new PathIcon { Data = (Geometry)this.FindResource("EditIcon")! } }, }, SecondaryCommands = { - new AppBarButton { Label = "Delete", Icon = new PathIcon { Data = (Geometry)this.FindResource("DeleteIcon")! } } + new CommandBarButton { Label = "Delete", Icon = new PathIcon { Data = (Geometry)this.FindResource("DeleteIcon")! } } } }; } diff --git a/src/Avalonia.Controls/CommandBar/CommandBar.cs b/src/Avalonia.Controls/CommandBar/CommandBar.cs index 392ffb7ddc..d493bce304 100644 --- a/src/Avalonia.Controls/CommandBar/CommandBar.cs +++ b/src/Avalonia.Controls/CommandBar/CommandBar.cs @@ -516,7 +516,7 @@ namespace Avalonia.Controls int primaryNonSepCount = 0; foreach (var item in PrimaryCommands) - if (item is not AppBarSeparator) + if (item is not CommandBarSeparator) primaryNonSepCount++; const double overflowButtonWidth = 48; @@ -554,7 +554,7 @@ namespace Avalonia.Controls for (var i = 0; i < prioritized.Count; i++) { var idx = prioritized[i].Index; - if (PrimaryCommands[idx] is AppBarSeparator) + if (PrimaryCommands[idx] is CommandBarSeparator) visibleIndices.Add(idx); else if (nonSeparatorCount < maxItems) { @@ -596,8 +596,8 @@ namespace Avalonia.Controls private static int GetDynamicOverflowOrder(ICommandBarElement element) => element switch { - AppBarButton b => b.DynamicOverflowOrder, - AppBarToggleButton t => t.DynamicOverflowOrder, + CommandBarButton b => b.DynamicOverflowOrder, + CommandBarToggleButton t => t.DynamicOverflowOrder, _ => 0 }; @@ -615,9 +615,9 @@ namespace Avalonia.Controls { element.IsCompact = DefaultLabelPosition == CommandBarDefaultLabelPosition.Collapsed; - if (element is AppBarButton abb) + if (element is CommandBarButton abb) abb.LabelPosition = DefaultLabelPosition; - else if (element is AppBarToggleButton atb) + else if (element is CommandBarToggleButton atb) atb.LabelPosition = DefaultLabelPosition; } diff --git a/src/Avalonia.Controls/CommandBar/AppBarButton.cs b/src/Avalonia.Controls/CommandBar/CommandBarButton.cs similarity index 80% rename from src/Avalonia.Controls/CommandBar/AppBarButton.cs rename to src/Avalonia.Controls/CommandBar/CommandBarButton.cs index 9e251f77bf..776f4edc47 100644 --- a/src/Avalonia.Controls/CommandBar/AppBarButton.cs +++ b/src/Avalonia.Controls/CommandBar/CommandBarButton.cs @@ -3,49 +3,49 @@ namespace Avalonia.Controls /// /// A button for use in a . /// - public class AppBarButton : Button, ICommandBarElement + public class CommandBarButton : Button, ICommandBarElement { - static AppBarButton() + static CommandBarButton() { - ForegroundProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); - IconProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); + ForegroundProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); + IconProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); } /// /// Defines the property. /// public static readonly StyledProperty LabelProperty = - AvaloniaProperty.Register(nameof(Label)); + AvaloniaProperty.Register(nameof(Label)); /// /// Defines the property. /// public static readonly StyledProperty IconProperty = - AvaloniaProperty.Register(nameof(Icon)); + AvaloniaProperty.Register(nameof(Icon)); /// /// Defines the property. /// public static readonly StyledProperty IsCompactProperty = - AvaloniaProperty.Register(nameof(IsCompact)); + AvaloniaProperty.Register(nameof(IsCompact)); /// /// Defines the property. /// public static readonly StyledProperty DynamicOverflowOrderProperty = - AvaloniaProperty.Register(nameof(DynamicOverflowOrder)); + AvaloniaProperty.Register(nameof(DynamicOverflowOrder)); /// /// Defines the property. /// public static readonly StyledProperty LabelPositionProperty = - AvaloniaProperty.Register(nameof(LabelPosition), CommandBarDefaultLabelPosition.Bottom); + AvaloniaProperty.Register(nameof(LabelPosition), CommandBarDefaultLabelPosition.Bottom); /// /// Defines the property. /// public static readonly StyledProperty IsInOverflowProperty = - AvaloniaProperty.Register(nameof(IsInOverflow)); + AvaloniaProperty.Register(nameof(IsInOverflow)); /// /// Gets or sets the text label for the button. diff --git a/src/Avalonia.Controls/CommandBar/AppBarSeparator.cs b/src/Avalonia.Controls/CommandBar/CommandBarSeparator.cs similarity index 81% rename from src/Avalonia.Controls/CommandBar/AppBarSeparator.cs rename to src/Avalonia.Controls/CommandBar/CommandBarSeparator.cs index 53c114528f..16f3868441 100644 --- a/src/Avalonia.Controls/CommandBar/AppBarSeparator.cs +++ b/src/Avalonia.Controls/CommandBar/CommandBarSeparator.cs @@ -1,23 +1,21 @@ -using Avalonia.Controls.Primitives; - namespace Avalonia.Controls { /// /// A visual separator for use in a . /// - public class AppBarSeparator : TemplatedControl, ICommandBarElement + public class CommandBarSeparator : Separator, ICommandBarElement { /// /// Defines the property. /// public static readonly StyledProperty IsCompactProperty = - AvaloniaProperty.Register(nameof(IsCompact)); + AvaloniaProperty.Register(nameof(IsCompact)); /// /// Defines the property. /// public static readonly StyledProperty IsInOverflowProperty = - AvaloniaProperty.Register(nameof(IsInOverflow)); + AvaloniaProperty.Register(nameof(IsInOverflow)); /// /// Gets or sets whether the separator is in compact mode. diff --git a/src/Avalonia.Controls/CommandBar/AppBarToggleButton.cs b/src/Avalonia.Controls/CommandBar/CommandBarToggleButton.cs similarity index 79% rename from src/Avalonia.Controls/CommandBar/AppBarToggleButton.cs rename to src/Avalonia.Controls/CommandBar/CommandBarToggleButton.cs index 2476e3cb93..6d7ce4b61e 100644 --- a/src/Avalonia.Controls/CommandBar/AppBarToggleButton.cs +++ b/src/Avalonia.Controls/CommandBar/CommandBarToggleButton.cs @@ -5,49 +5,49 @@ namespace Avalonia.Controls /// /// A toggle button for use in a . /// - public class AppBarToggleButton : ToggleButton, ICommandBarElement + public class CommandBarToggleButton : ToggleButton, ICommandBarElement { - static AppBarToggleButton() + static CommandBarToggleButton() { - ForegroundProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); - IconProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); + ForegroundProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); + IconProperty.Changed.AddClassHandler((x, _) => x.UpdateIconForeground()); } /// /// Defines the property. /// public static readonly StyledProperty LabelProperty = - AvaloniaProperty.Register(nameof(Label)); + AvaloniaProperty.Register(nameof(Label)); /// /// Defines the property. /// public static readonly StyledProperty IconProperty = - AvaloniaProperty.Register(nameof(Icon)); + AvaloniaProperty.Register(nameof(Icon)); /// /// Defines the property. /// public static readonly StyledProperty IsCompactProperty = - AvaloniaProperty.Register(nameof(IsCompact)); + AvaloniaProperty.Register(nameof(IsCompact)); /// /// Defines the property. /// public static readonly StyledProperty DynamicOverflowOrderProperty = - AvaloniaProperty.Register(nameof(DynamicOverflowOrder)); + AvaloniaProperty.Register(nameof(DynamicOverflowOrder)); /// /// Defines the property. /// public static readonly StyledProperty LabelPositionProperty = - AvaloniaProperty.Register(nameof(LabelPosition), CommandBarDefaultLabelPosition.Bottom); + AvaloniaProperty.Register(nameof(LabelPosition), CommandBarDefaultLabelPosition.Bottom); /// /// Defines the property. /// public static readonly StyledProperty IsInOverflowProperty = - AvaloniaProperty.Register(nameof(IsInOverflow)); + AvaloniaProperty.Register(nameof(IsInOverflow)); /// /// Gets or sets the text label for the button. diff --git a/src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml b/src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml index 45ad19cb93..8692a6153f 100644 --- a/src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/CommandBar.xaml @@ -5,29 +5,29 @@ - - + + - - - - + + + + - - - - - + + + + + - - + + - - + + @@ -108,8 +108,8 @@ - - + + @@ -195,8 +195,8 @@ - - + + diff --git a/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml b/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml index 45eee95081..3642f5fc22 100644 --- a/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/NavigationPage.xaml @@ -159,11 +159,11 @@ - - diff --git a/src/Avalonia.Themes.Simple/Controls/CommandBar.xaml b/src/Avalonia.Themes.Simple/Controls/CommandBar.xaml index f1384dc892..b489a20c4d 100644 --- a/src/Avalonia.Themes.Simple/Controls/CommandBar.xaml +++ b/src/Avalonia.Themes.Simple/Controls/CommandBar.xaml @@ -5,23 +5,23 @@ - - + + - - - - + + + + - - + + - - + + @@ -102,8 +102,8 @@ - - + + @@ -190,8 +190,8 @@ - - + + diff --git a/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs b/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs index 8bd4401d10..eabb6e33c1 100644 --- a/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CommandBarTests.cs @@ -6,27 +6,27 @@ using Xunit; namespace Avalonia.Controls.UnitTests; -public class AppBarButtonTests : ScopedTestBase +public class CommandBarButtonTests : ScopedTestBase { [Fact] public void Label_DefaultIsNull() - => Assert.Null(new AppBarButton().Label); + => Assert.Null(new CommandBarButton().Label); [Fact] public void Label_RoundTrip() { - var btn = new AppBarButton { Label = "Save" }; + var btn = new CommandBarButton { Label = "Save" }; Assert.Equal("Save", btn.Label); } [Fact] public void Icon_DefaultIsNull() - => Assert.Null(new AppBarButton().Icon); + => Assert.Null(new CommandBarButton().Icon); [Fact] public void Icon_RoundTrip() { - var btn = new AppBarButton(); + var btn = new CommandBarButton(); var icon = new object(); btn.Icon = icon; Assert.Same(icon, btn.Icon); @@ -34,72 +34,72 @@ public class AppBarButtonTests : ScopedTestBase [Fact] public void IsCompact_DefaultIsFalse() - => Assert.False(new AppBarButton().IsCompact); + => Assert.False(new CommandBarButton().IsCompact); [Fact] public void IsCompact_RoundTrip() { - var btn = new AppBarButton { IsCompact = true }; + var btn = new CommandBarButton { IsCompact = true }; Assert.True(btn.IsCompact); } [Fact] public void DynamicOverflowOrder_DefaultIsZero() - => Assert.Equal(0, new AppBarButton().DynamicOverflowOrder); + => Assert.Equal(0, new CommandBarButton().DynamicOverflowOrder); [Fact] public void DynamicOverflowOrder_RoundTrip() { - var btn = new AppBarButton { DynamicOverflowOrder = 3 }; + var btn = new CommandBarButton { DynamicOverflowOrder = 3 }; Assert.Equal(3, btn.DynamicOverflowOrder); } [Fact] public void LabelPosition_DefaultIsBottom() - => Assert.Equal(CommandBarDefaultLabelPosition.Bottom, new AppBarButton().LabelPosition); + => Assert.Equal(CommandBarDefaultLabelPosition.Bottom, new CommandBarButton().LabelPosition); [Fact] public void LabelPosition_RoundTrip() { - var btn = new AppBarButton { LabelPosition = CommandBarDefaultLabelPosition.Right }; + var btn = new CommandBarButton { LabelPosition = CommandBarDefaultLabelPosition.Right }; Assert.Equal(CommandBarDefaultLabelPosition.Right, btn.LabelPosition); } [Fact] public void IsInOverflow_DefaultIsFalse() - => Assert.False(new AppBarButton().IsInOverflow); + => Assert.False(new CommandBarButton().IsInOverflow); [Fact] public void IsInOverflow_RoundTrip() { - var btn = new AppBarButton { IsInOverflow = true }; + var btn = new CommandBarButton { IsInOverflow = true }; Assert.True(btn.IsInOverflow); } [Fact] public void ImplementsICommandBarElement() - => Assert.IsAssignableFrom(new AppBarButton()); + => Assert.IsAssignableFrom(new CommandBarButton()); [Fact] public void ICommandBarElement_IsCompact_ReadWrite() { - ICommandBarElement elem = new AppBarButton(); + ICommandBarElement elem = new CommandBarButton(); elem.IsCompact = true; Assert.True(elem.IsCompact); } [Fact] public void Command_DefaultIsNull() - => Assert.Null(new AppBarButton().Command); + => Assert.Null(new CommandBarButton().Command); [Fact] public void CommandParameter_DefaultIsNull() - => Assert.Null(new AppBarButton().CommandParameter); + => Assert.Null(new CommandBarButton().CommandParameter); [Fact] public void Command_RoundTrip() { - var btn = new AppBarButton(); + var btn = new CommandBarButton(); var cmd = new DelegateCommand(_ => { }); btn.Command = cmd; Assert.Same(cmd, btn.Command); @@ -108,85 +108,85 @@ public class AppBarButtonTests : ScopedTestBase [Fact] public void CommandParameter_RoundTrip() { - var btn = new AppBarButton { CommandParameter = "param" }; + var btn = new CommandBarButton { CommandParameter = "param" }; Assert.Equal("param", btn.CommandParameter); } } -public class AppBarToggleButtonTests : ScopedTestBase +public class CommandBarToggleButtonTests : ScopedTestBase { [Fact] public void Label_DefaultIsNull() - => Assert.Null(new AppBarToggleButton().Label); + => Assert.Null(new CommandBarToggleButton().Label); [Fact] public void Label_RoundTrip() { - var btn = new AppBarToggleButton { Label = "Bold" }; + var btn = new CommandBarToggleButton { Label = "Bold" }; Assert.Equal("Bold", btn.Label); } [Fact] public void Icon_DefaultIsNull() - => Assert.Null(new AppBarToggleButton().Icon); + => Assert.Null(new CommandBarToggleButton().Icon); [Fact] public void IsCompact_DefaultIsFalse() - => Assert.False(new AppBarToggleButton().IsCompact); + => Assert.False(new CommandBarToggleButton().IsCompact); [Fact] public void IsCompact_RoundTrip() { - var btn = new AppBarToggleButton { IsCompact = true }; + var btn = new CommandBarToggleButton { IsCompact = true }; Assert.True(btn.IsCompact); } [Fact] public void DynamicOverflowOrder_DefaultIsZero() - => Assert.Equal(0, new AppBarToggleButton().DynamicOverflowOrder); + => Assert.Equal(0, new CommandBarToggleButton().DynamicOverflowOrder); [Fact] public void DynamicOverflowOrder_RoundTrip() { - var btn = new AppBarToggleButton { DynamicOverflowOrder = 5 }; + var btn = new CommandBarToggleButton { DynamicOverflowOrder = 5 }; Assert.Equal(5, btn.DynamicOverflowOrder); } [Fact] public void LabelPosition_DefaultIsBottom() - => Assert.Equal(CommandBarDefaultLabelPosition.Bottom, new AppBarToggleButton().LabelPosition); + => Assert.Equal(CommandBarDefaultLabelPosition.Bottom, new CommandBarToggleButton().LabelPosition); [Fact] public void LabelPosition_RoundTrip() { - var btn = new AppBarToggleButton { LabelPosition = CommandBarDefaultLabelPosition.Collapsed }; + var btn = new CommandBarToggleButton { LabelPosition = CommandBarDefaultLabelPosition.Collapsed }; Assert.Equal(CommandBarDefaultLabelPosition.Collapsed, btn.LabelPosition); } [Fact] public void IsInOverflow_DefaultIsFalse() - => Assert.False(new AppBarToggleButton().IsInOverflow); + => Assert.False(new CommandBarToggleButton().IsInOverflow); [Fact] public void ImplementsICommandBarElement() - => Assert.IsAssignableFrom(new AppBarToggleButton()); + => Assert.IsAssignableFrom(new CommandBarToggleButton()); [Fact] public void ICommandBarElement_IsCompact_ReadWrite() { - ICommandBarElement elem = new AppBarToggleButton(); + ICommandBarElement elem = new CommandBarToggleButton(); elem.IsCompact = true; Assert.True(elem.IsCompact); } [Fact] public void Command_DefaultIsNull() - => Assert.Null(new AppBarToggleButton().Command); + => Assert.Null(new CommandBarToggleButton().Command); [Fact] public void Command_RoundTrip() { - var btn = new AppBarToggleButton(); + var btn = new CommandBarToggleButton(); var cmd = new DelegateCommand(_ => { }); btn.Command = cmd; Assert.Same(cmd, btn.Command); @@ -195,43 +195,47 @@ public class AppBarToggleButtonTests : ScopedTestBase [Fact] public void CommandParameter_RoundTrip() { - var btn = new AppBarToggleButton { CommandParameter = 42 }; + var btn = new CommandBarToggleButton { CommandParameter = 42 }; Assert.Equal(42, btn.CommandParameter); } } -public class AppBarSeparatorTests : ScopedTestBase +public class CommandBarSeparatorTests : ScopedTestBase { [Fact] public void IsCompact_DefaultIsFalse() - => Assert.False(new AppBarSeparator().IsCompact); + => Assert.False(new CommandBarSeparator().IsCompact); [Fact] public void IsCompact_RoundTrip() { - var sep = new AppBarSeparator { IsCompact = true }; + var sep = new CommandBarSeparator { IsCompact = true }; Assert.True(sep.IsCompact); } [Fact] public void IsInOverflow_DefaultIsFalse() - => Assert.False(new AppBarSeparator().IsInOverflow); + => Assert.False(new CommandBarSeparator().IsInOverflow); [Fact] public void IsInOverflow_RoundTrip() { - var sep = new AppBarSeparator { IsInOverflow = true }; + var sep = new CommandBarSeparator { IsInOverflow = true }; Assert.True(sep.IsInOverflow); } [Fact] public void ImplementsICommandBarElement() - => Assert.IsAssignableFrom(new AppBarSeparator()); + => Assert.IsAssignableFrom(new CommandBarSeparator()); + + [Fact] + public void DerivesFromSeparator() + => Assert.IsAssignableFrom(new CommandBarSeparator()); [Fact] public void ICommandBarElement_IsCompact_ReadWrite() { - ICommandBarElement elem = new AppBarSeparator(); + ICommandBarElement elem = new CommandBarSeparator(); elem.IsCompact = true; Assert.True(elem.IsCompact); } @@ -517,7 +521,7 @@ public class CommandBarCollectionTests : ScopedTestBase public void PrimaryCommands_Added_AppearInVisiblePrimary_WhenDynamicOverflowDisabled() { var cb = new CommandBar(); - var btn = new AppBarButton { Label = "Save" }; + var btn = new CommandBarButton { Label = "Save" }; cb.PrimaryCommands!.Add(btn); Assert.Contains(btn, cb.VisiblePrimaryCommands); } @@ -530,7 +534,7 @@ public class CommandBarCollectionTests : ScopedTestBase ((INotifyCollectionChanged)cb.VisiblePrimaryCommands).CollectionChanged += (_, _) => notifications++; - cb.PrimaryCommands!.Add(new AppBarButton { Label = "Save" }); + cb.PrimaryCommands!.Add(new CommandBarButton { Label = "Save" }); Assert.Equal(2, notifications); } @@ -539,7 +543,7 @@ public class CommandBarCollectionTests : ScopedTestBase public void PrimaryCommands_Removed_DisappearsFromVisiblePrimary() { var cb = new CommandBar(); - var btn = new AppBarButton { Label = "Save" }; + var btn = new CommandBarButton { Label = "Save" }; cb.PrimaryCommands!.Add(btn); cb.PrimaryCommands!.Remove(btn); Assert.DoesNotContain(btn, cb.VisiblePrimaryCommands); @@ -549,7 +553,7 @@ public class CommandBarCollectionTests : ScopedTestBase public void SecondaryCommands_Added_AppearInOverflowItems() { var cb = new CommandBar(); - var btn = new AppBarButton { Label = "Settings" }; + var btn = new CommandBarButton { Label = "Settings" }; cb.SecondaryCommands!.Add(btn); Assert.Contains(btn, cb.OverflowItems); } @@ -562,7 +566,7 @@ public class CommandBarCollectionTests : ScopedTestBase ((INotifyCollectionChanged)cb.OverflowItems).CollectionChanged += (_, _) => notifications++; - cb.SecondaryCommands!.Add(new AppBarButton { Label = "Settings" }); + cb.SecondaryCommands!.Add(new CommandBarButton { Label = "Settings" }); Assert.Equal(2, notifications); } @@ -571,7 +575,7 @@ public class CommandBarCollectionTests : ScopedTestBase public void SecondaryCommands_Removed_DisappearsFromOverflowItems() { var cb = new CommandBar(); - var btn = new AppBarButton { Label = "Settings" }; + var btn = new CommandBarButton { Label = "Settings" }; cb.SecondaryCommands!.Add(btn); cb.SecondaryCommands!.Remove(btn); Assert.DoesNotContain(btn, cb.OverflowItems); @@ -581,7 +585,7 @@ public class CommandBarCollectionTests : ScopedTestBase public void HasSecondaryCommands_TrueWhenSecondaryAdded() { var cb = new CommandBar(); - cb.SecondaryCommands!.Add(new AppBarButton { Label = "Options" }); + cb.SecondaryCommands!.Add(new CommandBarButton { Label = "Options" }); Assert.True(cb.HasSecondaryCommands); } @@ -589,7 +593,7 @@ public class CommandBarCollectionTests : ScopedTestBase public void HasSecondaryCommands_FalseAfterSecondaryCleared() { var cb = new CommandBar(); - var btn = new AppBarButton { Label = "Options" }; + var btn = new CommandBarButton { Label = "Options" }; cb.SecondaryCommands!.Add(btn); cb.SecondaryCommands!.Remove(btn); Assert.False(cb.HasSecondaryCommands); @@ -599,8 +603,8 @@ public class CommandBarCollectionTests : ScopedTestBase public void OverflowItems_CountMatchesSecondaryCommandCount() { var cb = new CommandBar(); - cb.SecondaryCommands!.Add(new AppBarButton()); - cb.SecondaryCommands!.Add(new AppBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); Assert.Equal(2, cb.OverflowItems.Count); } @@ -608,8 +612,8 @@ public class CommandBarCollectionTests : ScopedTestBase public void VisiblePrimaryCommands_CountMatchesPrimary_WhenDynamicOverflowDisabled() { var cb = new CommandBar(); - cb.PrimaryCommands!.Add(new AppBarButton()); - cb.PrimaryCommands!.Add(new AppBarButton()); + cb.PrimaryCommands!.Add(new CommandBarButton()); + cb.PrimaryCommands!.Add(new CommandBarButton()); Assert.Equal(2, cb.VisiblePrimaryCommands.Count); } @@ -617,9 +621,9 @@ public class CommandBarCollectionTests : ScopedTestBase public void MultiplePrimaryCommands_AllVisibleInOrder() { var cb = new CommandBar(); - var btn1 = new AppBarButton { Label = "A" }; - var btn2 = new AppBarButton { Label = "B" }; - var btn3 = new AppBarButton { Label = "C" }; + var btn1 = new CommandBarButton { Label = "A" }; + var btn2 = new CommandBarButton { Label = "B" }; + var btn3 = new CommandBarButton { Label = "C" }; cb.PrimaryCommands!.Add(btn1); cb.PrimaryCommands!.Add(btn2); cb.PrimaryCommands!.Add(btn3); @@ -627,19 +631,19 @@ public class CommandBarCollectionTests : ScopedTestBase } [Fact] - public void AppBarSeparator_CanBeAddedToPrimaryCommands() + public void CommandBarSeparator_CanBeAddedToPrimaryCommands() { var cb = new CommandBar(); - var sep = new AppBarSeparator(); + var sep = new CommandBarSeparator(); cb.PrimaryCommands!.Add(sep); Assert.Contains(sep, cb.VisiblePrimaryCommands); } [Fact] - public void AppBarToggleButton_CanBeAddedToPrimaryCommands() + public void CommandBarToggleButton_CanBeAddedToPrimaryCommands() { var cb = new CommandBar(); - var toggle = new AppBarToggleButton { Label = "Bold" }; + var toggle = new CommandBarToggleButton { Label = "Bold" }; cb.PrimaryCommands!.Add(toggle); Assert.Contains(toggle, cb.VisiblePrimaryCommands); } @@ -651,7 +655,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase public void DefaultLabelPosition_Collapsed_SetsIsCompactOnExistingPrimaryButton() { var cb = new CommandBar(); - var btn = new AppBarButton(); + var btn = new CommandBarButton(); cb.PrimaryCommands!.Add(btn); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Collapsed; @@ -663,7 +667,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase public void DefaultLabelPosition_Bottom_ClearsIsCompactOnPrimaryButton() { var cb = new CommandBar(); - var btn = new AppBarButton(); + var btn = new CommandBarButton(); cb.PrimaryCommands!.Add(btn); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Collapsed; @@ -676,7 +680,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase public void DefaultLabelPosition_Right_SetsLabelPositionOnPrimaryButton() { var cb = new CommandBar(); - var btn = new AppBarButton(); + var btn = new CommandBarButton(); cb.PrimaryCommands!.Add(btn); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Right; @@ -688,7 +692,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase public void DefaultLabelPosition_Collapsed_SetsLabelPositionOnPrimaryButton() { var cb = new CommandBar(); - var btn = new AppBarButton(); + var btn = new CommandBarButton(); cb.PrimaryCommands!.Add(btn); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Collapsed; @@ -700,7 +704,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase public void DefaultLabelPosition_Collapsed_PropagatesIsCompactToToggleButton() { var cb = new CommandBar(); - var toggle = new AppBarToggleButton(); + var toggle = new CommandBarToggleButton(); cb.PrimaryCommands!.Add(toggle); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Collapsed; @@ -713,7 +717,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase public void DefaultLabelPosition_Right_PropagatesLabelPositionToToggleButton() { var cb = new CommandBar(); - var toggle = new AppBarToggleButton(); + var toggle = new CommandBarToggleButton(); cb.PrimaryCommands!.Add(toggle); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Right; @@ -725,7 +729,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase public void DefaultLabelPosition_Collapsed_SetsIsCompactOnSeparator() { var cb = new CommandBar(); - var sep = new AppBarSeparator(); + var sep = new CommandBarSeparator(); cb.PrimaryCommands!.Add(sep); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Collapsed; @@ -738,7 +742,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase { var cb = new CommandBar { DefaultLabelPosition = CommandBarDefaultLabelPosition.Collapsed }; - var btn = new AppBarButton(); + var btn = new CommandBarButton(); cb.PrimaryCommands!.Add(btn); Assert.True(btn.IsCompact); @@ -750,7 +754,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase { var cb = new CommandBar { DefaultLabelPosition = CommandBarDefaultLabelPosition.Right }; - var btn = new AppBarButton(); + var btn = new CommandBarButton(); cb.PrimaryCommands!.Add(btn); Assert.Equal(CommandBarDefaultLabelPosition.Right, btn.LabelPosition); @@ -760,7 +764,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase public void DefaultLabelPosition_Collapsed_AppliesToSecondaryCommands() { var cb = new CommandBar(); - var btn = new AppBarButton(); + var btn = new CommandBarButton(); cb.SecondaryCommands!.Add(btn); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Collapsed; @@ -772,7 +776,7 @@ public class CommandBarLabelPositionTests : ScopedTestBase public void DefaultLabelPosition_DoesNotClearLabelText() { var cb = new CommandBar(); - var btn = new AppBarButton { Label = "Save" }; + var btn = new CommandBarButton { Label = "Save" }; cb.PrimaryCommands!.Add(btn); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Collapsed; @@ -801,7 +805,7 @@ public class CommandBarOverflowButtonTests : ScopedTestBase public void OverflowButtonVisibility_Auto_TrueWhenHasSecondaryCommands() { var cb = new CommandBar(); - cb.SecondaryCommands!.Add(new AppBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); Assert.True(cb.IsOverflowButtonVisible); } @@ -823,7 +827,7 @@ public class CommandBarOverflowButtonTests : ScopedTestBase public void OverflowButtonVisibility_Collapsed_RemainsFalseEvenWithSecondary() { var cb = new CommandBar { OverflowButtonVisibility = CommandBarOverflowButtonVisibility.Collapsed }; - cb.SecondaryCommands!.Add(new AppBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); Assert.False(cb.IsOverflowButtonVisible); } @@ -831,7 +835,7 @@ public class CommandBarOverflowButtonTests : ScopedTestBase public void OverflowButtonVisibility_Auto_FalseAfterSecondaryRemoved() { var cb = new CommandBar(); - var btn = new AppBarButton(); + var btn = new CommandBarButton(); cb.SecondaryCommands!.Add(btn); Assert.True(cb.IsOverflowButtonVisible); @@ -863,9 +867,9 @@ public class CommandBarItemWidthTests : ScopedTestBase public void ItemWidthBottom_Controls_HowManyButtonsFit() { var cb = CreateWithWidth(300); - cb.SecondaryCommands!.Add(new AppBarButton()); // forces overflow button + cb.SecondaryCommands!.Add(new CommandBarButton()); // forces overflow button for (int i = 0; i < 4; i++) - cb.PrimaryCommands!.Add(new AppBarButton()); + cb.PrimaryCommands!.Add(new CommandBarButton()); cb.IsDynamicOverflowEnabled = true; Assert.Equal(3, cb.VisiblePrimaryCommands.Count); @@ -877,9 +881,9 @@ public class CommandBarItemWidthTests : ScopedTestBase { var cb = CreateWithWidth(300); cb.ItemWidthBottom = 35; - cb.SecondaryCommands!.Add(new AppBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); for (int i = 0; i < 4; i++) - cb.PrimaryCommands!.Add(new AppBarButton()); + cb.PrimaryCommands!.Add(new CommandBarButton()); cb.IsDynamicOverflowEnabled = true; Assert.Equal(4, cb.VisiblePrimaryCommands.Count); @@ -890,9 +894,9 @@ public class CommandBarItemWidthTests : ScopedTestBase { var cb = CreateWithWidth(300); cb.ItemWidthBottom = 260; - cb.SecondaryCommands!.Add(new AppBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); for (int i = 0; i < 3; i++) - cb.PrimaryCommands!.Add(new AppBarButton()); + cb.PrimaryCommands!.Add(new CommandBarButton()); cb.IsDynamicOverflowEnabled = true; Assert.Equal(1, cb.VisiblePrimaryCommands.Count); @@ -903,9 +907,9 @@ public class CommandBarItemWidthTests : ScopedTestBase { var cb = CreateWithWidth(300); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Right; - cb.SecondaryCommands!.Add(new AppBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); for (int i = 0; i < 4; i++) - cb.PrimaryCommands!.Add(new AppBarButton()); + cb.PrimaryCommands!.Add(new CommandBarButton()); cb.IsDynamicOverflowEnabled = true; Assert.Equal(2, cb.VisiblePrimaryCommands.Count); @@ -917,9 +921,9 @@ public class CommandBarItemWidthTests : ScopedTestBase var cb = CreateWithWidth(300); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Right; cb.ItemWidthRight = 252; // exactly 1 fits: 252/252=1 - cb.SecondaryCommands!.Add(new AppBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); for (int i = 0; i < 3; i++) - cb.PrimaryCommands!.Add(new AppBarButton()); + cb.PrimaryCommands!.Add(new CommandBarButton()); cb.IsDynamicOverflowEnabled = true; Assert.Equal(1, cb.VisiblePrimaryCommands.Count); @@ -930,9 +934,9 @@ public class CommandBarItemWidthTests : ScopedTestBase { var cb = CreateWithWidth(300); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Collapsed; - cb.SecondaryCommands!.Add(new AppBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); for (int i = 0; i < 4; i++) - cb.PrimaryCommands!.Add(new AppBarButton()); + cb.PrimaryCommands!.Add(new CommandBarButton()); cb.IsDynamicOverflowEnabled = true; Assert.Equal(4, cb.VisiblePrimaryCommands.Count); @@ -945,9 +949,9 @@ public class CommandBarItemWidthTests : ScopedTestBase cb.ItemWidthBottom = 70; cb.ItemWidthRight = 102; cb.ItemWidthCollapsed = 42; - cb.SecondaryCommands!.Add(new AppBarButton()); + cb.SecondaryCommands!.Add(new CommandBarButton()); for (int i = 0; i < 4; i++) - cb.PrimaryCommands!.Add(new AppBarButton()); + cb.PrimaryCommands!.Add(new CommandBarButton()); cb.DefaultLabelPosition = CommandBarDefaultLabelPosition.Bottom; cb.IsDynamicOverflowEnabled = true; diff --git a/tests/Avalonia.RenderTests/Controls/CommandBarTests.cs b/tests/Avalonia.RenderTests/Controls/CommandBarTests.cs index a518851027..ebef5ce419 100644 --- a/tests/Avalonia.RenderTests/Controls/CommandBarTests.cs +++ b/tests/Avalonia.RenderTests/Controls/CommandBarTests.cs @@ -37,7 +37,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Background = Brushes.LightGray, PrimaryCommands = { - new AppBarButton + new CommandBarButton { Label = "New", Icon = new Path @@ -49,7 +49,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Stretch = Stretch.Uniform } }, - new AppBarButton + new CommandBarButton { Label = "Save", Icon = new Path @@ -61,8 +61,8 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Stretch = Stretch.Uniform } }, - new AppBarSeparator(), - new AppBarToggleButton + new CommandBarSeparator(), + new CommandBarToggleButton { Label = "Bold", Icon = new Path @@ -98,7 +98,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls OverflowButtonVisibility = CommandBarOverflowButtonVisibility.Collapsed, PrimaryCommands = { - new AppBarButton + new CommandBarButton { Icon = new Path { @@ -109,7 +109,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Stretch = Stretch.Uniform } }, - new AppBarButton + new CommandBarButton { Icon = new Path { @@ -120,8 +120,8 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Stretch = Stretch.Uniform } }, - new AppBarSeparator(), - new AppBarToggleButton + new CommandBarSeparator(), + new CommandBarToggleButton { IsChecked = true, Icon = new Path diff --git a/tests/Avalonia.RenderTests/Controls/ContentPageTests.cs b/tests/Avalonia.RenderTests/Controls/ContentPageTests.cs index 07ba5b6505..1d3c30c8e5 100644 --- a/tests/Avalonia.RenderTests/Controls/ContentPageTests.cs +++ b/tests/Avalonia.RenderTests/Controls/ContentPageTests.cs @@ -71,7 +71,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Background = Brushes.LightGray, PrimaryCommands = { - new AppBarButton + new CommandBarButton { Label = "Save", Icon = new Path @@ -83,8 +83,8 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Stretch = Stretch.Uniform } }, - new AppBarSeparator(), - new AppBarToggleButton + new CommandBarSeparator(), + new CommandBarToggleButton { Label = "Bold", Icon = new Path @@ -105,7 +105,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls OverflowButtonVisibility = CommandBarOverflowButtonVisibility.Collapsed, PrimaryCommands = { - new AppBarButton + new CommandBarButton { Icon = new Path { From 3edf20b931ca242880a1f7ecec61c58e9ec85e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Mon, 30 Mar 2026 19:20:40 +0200 Subject: [PATCH 36/55] Apply optimizations in Avalonia Android (#20968) * Apply optimizations around Avalonia Android * More changes * Move benchmarks to run on Android * Remove Android Benchmark from build * More changes * Removed Android Benchmarks project --- .../Avalonia.Android/AndroidDispatcherImpl.cs | 21 ++++++---------- .../Avalonia.Android/AvaloniaAccessHelper.cs | 17 ++++++++++--- .../Platform/SkiaPlatform/TopLevelImpl.cs | 16 +++++++----- .../Helpers/AndroidKeyboardEventsHelper.cs | 25 +++++++++++++++++-- 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/Android/Avalonia.Android/AndroidDispatcherImpl.cs b/src/Android/Avalonia.Android/AndroidDispatcherImpl.cs index 8ee5f2a8f0..ea23b64a42 100644 --- a/src/Android/Avalonia.Android/AndroidDispatcherImpl.cs +++ b/src/Android/Avalonia.Android/AndroidDispatcherImpl.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Threading; using Android.OS; using Avalonia.Controls.Documents; using Avalonia.Threading; @@ -19,8 +20,7 @@ namespace Avalonia.Android private readonly Runnable _timerSignaler; private readonly Runnable _wakeupSignaler; private readonly MessageQueue _queue; - private readonly object _lock = new(); - private bool _signaled; + private int _signaled; private bool _backgroundProcessingRequested; @@ -46,8 +46,7 @@ namespace Avalonia.Android public event Action? Signaled; private void OnSignaled() { - lock (_lock) - _signaled = false; + Interlocked.Exchange(ref _signaled, 0); Signaled?.Invoke(); } @@ -68,13 +67,11 @@ namespace Avalonia.Android public void Signal() { - lock (_lock) + if (Interlocked.CompareExchange(ref _signaled, 1, 0) != 0) { - if(_signaled) - return; - _signaled = true; - _handler.Post(_signaler); + return; } + _handler.Post(_signaler); } readonly Stopwatch _clock = Stopwatch.StartNew(); @@ -133,11 +130,9 @@ namespace Avalonia.Android // "background" jobs not being processed // So we need to examine the queue state to prevent that scenario - lock (_lock) + if (Volatile.Read(ref _signaled) != 0) { - // There are higher priority jobs enqueued, we'll be called again - if (_signaled) - return; + return; } if (CanQueryPendingInput) diff --git a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs index ca06998dbc..6d5128ece2 100644 --- a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs +++ b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Linq; + using Android.OS; using AndroidX.Core.View.Accessibility; using AndroidX.CustomView.Widget; @@ -134,9 +134,18 @@ namespace Avalonia.Android protected override bool OnPerformActionForVirtualView(int virtualViewId, int action, Bundle? arguments) { - return (GetNodeInfoProvidersFromVirtualViewId(virtualViewId) ?? []) - .Select(x => TryPerformNodeAction(x, action, arguments)) - .Aggregate(false, (a, b) => a | b); + var providers = GetNodeInfoProvidersFromVirtualViewId(virtualViewId); + if (providers == null) + { + return false; + } + + var result = false; + foreach (var provider in providers) + { + result |= TryPerformNodeAction(provider, action, arguments); + } + return result; } private static bool TryPerformNodeAction(INodeInfoProvider nodeInfoProvider, int action, Bundle? arguments) diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index 20284906be..da128cec96 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -4,6 +4,7 @@ using Android.App; using Android.Content; using Android.Graphics; using Android.Graphics.Drawables; +using Android.OS; using Android.Runtime; using Android.Views; using AndroidX.AppCompat.App; @@ -144,6 +145,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform private readonly TopLevelImpl _tl; private Size _oldSize; private double _oldScaling; + private Paint? _clearPaint; public SurfaceViewImpl(Context context, TopLevelImpl tl, bool placeOnTop) : base(context) { @@ -159,11 +161,13 @@ namespace Avalonia.Android.Platform.SkiaPlatform // can be seen below, but it does not. if (OperatingSystem.IsAndroidVersionAtLeast(29)) { - // Android 10+ does this (BlendMode was new) - var paint = new Paint(); - paint.SetColor(0); - paint.BlendMode = BlendMode.Clear; - canvas.DrawRect(0, 0, Width, Height, paint); + if (_clearPaint == null) + { + _clearPaint = new Paint(); + _clearPaint.SetColor(0); + _clearPaint.BlendMode = BlendMode.Clear; + } + canvas.DrawRect(0, 0, Width, Height, _clearPaint); } else { @@ -384,7 +388,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform { if(Input != null) { - var args = new RawTextInputEventArgs(AndroidKeyboardDevice.Instance!, (ulong)DateTime.Now.Ticks, InputRoot!, text); + var args = new RawTextInputEventArgs(AndroidKeyboardDevice.Instance!, (ulong)SystemClock.UptimeMillis(), InputRoot!, text); Input(args); } diff --git a/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs b/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs index 03be7c2153..59b1bc9899 100644 --- a/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs +++ b/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs @@ -10,6 +10,18 @@ namespace Avalonia.Android.Platform.Specific.Helpers { internal class AndroidKeyboardEventsHelper : IDisposable where TView : TopLevelImpl { + private static readonly string[] s_asciiStringCache = InitAsciiStringCache(); + + private static string[] InitAsciiStringCache() + { + var cache = new string[128]; + for (int i = 0; i < 128; i++) + { + cache[i] = ((char)i).ToString(); + } + return cache; + } + private readonly TView _view; public bool HandleEvents { get; set; } @@ -79,7 +91,7 @@ namespace Avalonia.Android.Platform.Specific.Helpers AndroidKeyboardDevice.Instance!, Convert.ToUInt64(e.EventTime), inputRoot, - unicodeTextInput ?? Convert.ToChar(e.UnicodeChar).ToString() + unicodeTextInput ?? CharToString(e.UnicodeChar) ); _view.Input?.Invoke(rawTextEvent); @@ -107,6 +119,15 @@ namespace Avalonia.Android.Platform.Specific.Helpers return rv; } + private static string CharToString(int unicodeChar) + { + if (unicodeChar >= 0 && unicodeChar < s_asciiStringCache.Length) + { + return s_asciiStringCache[unicodeChar]; + } + return char.ConvertFromUtf32(unicodeChar); + } + private static string? GetKeySymbol(int unicodeChar, PhysicalKey physicalKey) { // Handle a very limited set of control characters so that we're consistent with other platforms @@ -126,7 +147,7 @@ namespace Avalonia.Android.Platform.Specific.Helpers if (unicodeChar <= 0x7F) { var asciiChar = (char)unicodeChar; - return KeySymbolHelper.IsAllowedAsciiKeySymbol(asciiChar) ? asciiChar.ToString() : null; + return KeySymbolHelper.IsAllowedAsciiKeySymbol(asciiChar) ? s_asciiStringCache[asciiChar] : null; } return char.ConvertFromUtf32(unicodeChar); } From 118a1174e215d52290a28500734790ac6a95ee09 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Mon, 30 Mar 2026 19:36:38 +0000 Subject: [PATCH 37/55] Fix Mouse pointer triggering focus change on pointer release. (#21009) * add tests * explicitly check which pointer events trigger focus change * add test checking for change in focus while mouse is pressed. --- src/Avalonia.Base/Input/FocusManager.cs | 14 ++- .../Input/InputElement_Focus.cs | 50 ++++----- .../Input/MouseDeviceTests.cs | 103 ++++++++++++++++-- .../Input/PointerTestsBase.cs | 2 - .../Input/TouchDeviceTests.cs | 67 ++++++++++-- 5 files changed, 186 insertions(+), 50 deletions(-) diff --git a/src/Avalonia.Base/Input/FocusManager.cs b/src/Avalonia.Base/Input/FocusManager.cs index a273ff6d89..651210fc2b 100644 --- a/src/Avalonia.Base/Input/FocusManager.cs +++ b/src/Avalonia.Base/Input/FocusManager.cs @@ -111,7 +111,7 @@ namespace Avalonia.Input scope.ClearValue(FocusedElementProperty); } - if (Current == removedElement) + if (Current == removedElement) Focus(null); } @@ -158,7 +158,7 @@ namespace Avalonia.Input /// internal static FocusManager? GetFocusManager(IInputElement? element) { - + // Element might not be a visual, and not attached to the root. // But IFocusManager is always expected to be a FocusManager. return (FocusManager?)(element as Visual)?.GetInputRoot()?.FocusManager @@ -188,8 +188,12 @@ namespace Avalonia.Input { if (CanFocus(e)) { - if (ev.Pointer.Type == PointerType.Mouse || ev is PointerReleasedEventArgs) - return true; + return ev switch + { + PointerReleasedEventArgs releasedEventArgs when releasedEventArgs.Pointer.Type != PointerType.Mouse => true, + PointerPressedEventArgs pressedEventArgs when pressedEventArgs.Pointer.Type == PointerType.Mouse => true, + _ => false, + }; } return false; @@ -229,7 +233,7 @@ namespace Avalonia.Input var root = v.PresentationSource?.InputRoot.FocusRoot as Visual; while (root is IHostedVisualTreeRoot hosted && - hosted.Host?.PresentationSource?.InputRoot.FocusRoot is {} parentRoot) + hosted.Host?.PresentationSource?.InputRoot.FocusRoot is { } parentRoot) { root = parentRoot; } diff --git a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs index a09eccc1a3..45d665b591 100644 --- a/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs +++ b/tests/Avalonia.Base.UnitTests/Input/InputElement_Focus.cs @@ -24,7 +24,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.Same(target, root.FocusManager.GetFocusedElement()); } } - + [Fact] public void Invisible_Controls_Should_Not_Receive_Focus() { @@ -34,20 +34,20 @@ namespace Avalonia.Base.UnitTests.Input { var root = new TestRoot { - Child = target = new Button() { IsVisible = false} + Child = target = new Button() { IsVisible = false } }; - + Assert.Null(root.FocusManager.GetFocusedElement()); target.Focus(); - + Assert.False(target.IsFocused); Assert.False(target.IsKeyboardFocusWithin); Assert.Null(root.FocusManager.GetFocusedElement()); } } - + [Fact] public void Effectively_Invisible_Controls_Should_Not_Receive_Focus() { @@ -64,11 +64,11 @@ namespace Avalonia.Base.UnitTests.Input Children = { target } } }; - + Assert.Null(root.FocusManager.GetFocusedElement()); target.Focus(); - + Assert.False(target.IsFocused); Assert.False(target.IsKeyboardFocusWithin); @@ -87,7 +87,7 @@ namespace Avalonia.Base.UnitTests.Input var root = new TestRoot { Child = new StackPanel - { + { Children = { (first = new Button()), @@ -365,7 +365,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.False(target2.Classes.Contains(":focus-visible")); } } - + [Fact] public void Control_FocusWithin_PseudoClass_Should_Be_Applied() { @@ -398,7 +398,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(root.IsKeyboardFocusWithin); } } - + [Fact] public void Control_FocusWithin_PseudoClass_Should_Be_Applied_and_Removed() { @@ -419,7 +419,7 @@ namespace Avalonia.Base.UnitTests.Input } } }; - + target1.ApplyTemplate(); target2.ApplyTemplate(); @@ -433,9 +433,9 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(root.Child.IsKeyboardFocusWithin); Assert.True(root.Classes.Contains(":focus-within")); Assert.True(root.IsKeyboardFocusWithin); - + target2.Focus(); - + Assert.False(target1.IsFocused); Assert.False(target1.Classes.Contains(":focus-within")); Assert.False(target1.IsKeyboardFocusWithin); @@ -445,7 +445,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(root.Child.IsKeyboardFocusWithin); Assert.True(root.Classes.Contains(":focus-within")); Assert.True(root.IsKeyboardFocusWithin); - + Assert.True(target2.IsFocused); Assert.True(target2.Classes.Contains(":focus-within")); Assert.True(target2.IsKeyboardFocusWithin); @@ -453,7 +453,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(panel2.IsKeyboardFocusWithin); } } - + [Fact] public void Control_FocusWithin_Pseudoclass_Should_Be_Removed_When_Removed_From_Tree() { @@ -487,11 +487,11 @@ namespace Avalonia.Base.UnitTests.Input var keyboardDevice = KeyboardDevice.Instance!; Assert.Equal(keyboardDevice.FocusedElement, target1); - + root.Child = null; - + Assert.Null(keyboardDevice.FocusedElement); - + Assert.False(target1.IsFocused); Assert.False(target1.Classes.Contains(":focus-within")); Assert.False(target1.IsKeyboardFocusWithin); @@ -499,7 +499,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.False(root.IsKeyboardFocusWithin); } } - + [Fact] public void Control_FocusWithin_Pseudoclass_Should_Be_Removed_Focus_Moves_To_Different_Root() { @@ -507,7 +507,7 @@ namespace Avalonia.Base.UnitTests.Input { var target1 = new Decorator { Focusable = true }; var target2 = new Decorator { Focusable = true }; - + var root1 = new TestRoot { Child = new StackPanel @@ -518,7 +518,7 @@ namespace Avalonia.Base.UnitTests.Input } } }; - + var root2 = new TestRoot { Child = new StackPanel @@ -543,9 +543,9 @@ namespace Avalonia.Base.UnitTests.Input Assert.True(root1.IsKeyboardFocusWithin); Assert.Equal(KeyboardDevice.Instance!.FocusedElement, target1); - + target2.Focus(); - + Assert.False(target1.IsFocused); Assert.False(target1.Classes.Contains(":focus-within")); Assert.False(target1.IsKeyboardFocusWithin); @@ -553,7 +553,7 @@ namespace Avalonia.Base.UnitTests.Input Assert.False(root1.Child.IsKeyboardFocusWithin); Assert.False(root1.Classes.Contains(":focus-within")); Assert.False(root1.IsKeyboardFocusWithin); - + Assert.True(target2.IsFocused); Assert.True(target2.Classes.Contains(":focus-within")); Assert.True(target2.IsKeyboardFocusWithin); @@ -1031,7 +1031,7 @@ namespace Avalonia.Base.UnitTests.Input [XYFocus.UpProperty] = target3, [XYFocus.DownProperty] = target4, }; - var container = new Canvas + var container = new Canvas { Children = { diff --git a/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs b/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs index 6380f71935..1ec6ec2be5 100644 --- a/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/MouseDeviceTests.cs @@ -4,7 +4,6 @@ using Avalonia.Input.Raw; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Rendering; -using Avalonia.Threading; using Avalonia.UnitTests; using Moq; using Xunit; @@ -18,7 +17,7 @@ namespace Avalonia.Base.UnitTests.Input { using var scope = AvaloniaLocator.EnterScope(); var settingsMock = new Mock(); - + AvaloniaLocator.CurrentMutable.BindToSelf(this) .Bind().ToConstant(settingsMock.Object); @@ -32,7 +31,7 @@ namespace Avalonia.Base.UnitTests.Input var control = new Control(); var root = CreateInputRoot(impl.Object, control, renderer.Object); - + MouseButton button = default; root.PointerReleased += (s, e) => button = e.InitialPressMouseButton; @@ -50,10 +49,10 @@ namespace Avalonia.Base.UnitTests.Input impl.Object.Input!(up); Assert.Equal(MouseButton.Left, button); - + impl.Object.Input!(up); - Assert.Equal(MouseButton.None, button); + Assert.Equal(MouseButton.None, button); } [Fact] @@ -85,7 +84,7 @@ namespace Avalonia.Base.UnitTests.Input impl.Object.Input!(CreateRawPointerMovedArgs(device, root)); Assert.NotNull(result); - + result.Capture(control); Assert.Same(control, result.Captured); @@ -115,8 +114,8 @@ namespace Avalonia.Base.UnitTests.Input }) } }, renderer.Object); - - + + Point? result = null; root.PointerMoved += (_, a) => { @@ -128,5 +127,93 @@ namespace Avalonia.Base.UnitTests.Input Assert.Equal(new Point(1, 11), result); } + + [Fact] + public void Mouse_Pointer_Should_Set_Focus_On_Pointer_Pressed() + { + using var scope = AvaloniaLocator.EnterScope(); + var settingsMock = new Mock(); + + AvaloniaLocator.CurrentMutable.BindToSelf(this) + .Bind().ToConstant(settingsMock.Object); + + using var app = UnitTestApplication.Start( + TestServices.RealFocus); + + var renderer = new Mock(); + var impl = CreateTopLevelImplMock(); + + var control = new Button() + { + Focusable = true + }; + var root = CreateInputRoot(impl.Object, control, renderer.Object); + + var device = new MouseDevice(); + + var down = CreateRawPointerArgs(device, root, RawPointerEventType.LeftButtonDown); + var up = CreateRawPointerArgs(device, root, RawPointerEventType.LeftButtonUp); + + SetHit(renderer, control); + + Assert.False(control.IsFocused); + + impl.Object.Input!(down); + + Assert.True(control.IsFocused); + impl.Object.Input!(up); + + Assert.True(control.IsFocused); + } + + [Fact] + public void Control_Should_Not_Gain_Focus_On_Mouse_Release() + { + using var scope = AvaloniaLocator.EnterScope(); + var settingsMock = new Mock(); + + AvaloniaLocator.CurrentMutable.BindToSelf(this) + .Bind().ToConstant(settingsMock.Object); + + using var app = UnitTestApplication.Start( + TestServices.RealFocus); + + var renderer = new Mock(); + var impl = CreateTopLevelImplMock(); + + var control1 = new Button() + { + Focusable = true + }; + + var control2 = new Button() + { + Focusable = true + }; + var stack = new StackPanel() + { + Children = { control1, control2 } + }; + var root = CreateInputRoot(impl.Object, stack, renderer.Object); + + var device = new MouseDevice(); + + var down = CreateRawPointerArgs(device, root, RawPointerEventType.LeftButtonDown); + var up = CreateRawPointerArgs(device, root, RawPointerEventType.LeftButtonUp); + + SetHit(renderer, control1); + + Assert.False(control1.IsFocused); + + impl.Object.Input!(down); + + Assert.True(control1.IsFocused); + + control2.Focus(); + + impl.Object.Input!(up); + + Assert.False(control1.IsFocused); + } } } diff --git a/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs b/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs index c75ff56d82..1675dc9e62 100644 --- a/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs +++ b/tests/Avalonia.Base.UnitTests/Input/PointerTestsBase.cs @@ -7,9 +7,7 @@ using Avalonia.Input; using Avalonia.Input.Raw; using Avalonia.Platform; using Avalonia.Rendering; -using Avalonia.Rendering.Composition; using Avalonia.UnitTests; -using Avalonia.VisualTree; using Moq; namespace Avalonia.Base.UnitTests.Input; diff --git a/tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs b/tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs index 97f83b1a69..600856c643 100644 --- a/tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/TouchDeviceTests.cs @@ -1,14 +1,16 @@ using System; +using Avalonia.Base.UnitTests.Input; +using Avalonia.Controls; using Avalonia.Input.Raw; using Avalonia.Platform; -using Avalonia.Threading; +using Avalonia.Rendering; using Avalonia.UnitTests; using Moq; using Xunit; namespace Avalonia.Input.UnitTests { - public class TouchDeviceTests + public class TouchDeviceTests : PointerTestsBase { [Fact] public void Tapped_Event_Is_Fired_With_Touch() @@ -141,6 +143,36 @@ namespace Avalonia.Input.UnitTests Assert.Equal(0, doubleTappedExecutedTimes); } + [Fact] + public void Touch_Pointer_Should_Set_Focus_On_Pointer_Released() + { + using var scope = AvaloniaLocator.EnterScope(); + using var app = UnitTestApplication.Start( + TestServices.RealFocus); + + var impl = CreateTopLevelImplMock(); + + var renderer = new Mock(); + var root = new TestTopLevel(impl.Object) + { + HitTesterOverride = renderer.Object, + }; + var host = root.TopLevelHost; + + host.Focusable = true; + var touchDevice = new TouchDevice(); + var inputManager = InputManager.Instance!; + + Assert.False(host.IsFocused); + + Press(InputManager.Instance!, touchDevice, root.InputRoot); + + Assert.False(host.IsFocused); + Release(InputManager.Instance!, touchDevice, root.InputRoot); + + Assert.True(host.IsFocused); + } + [Fact] public void Click_Counting_Should_Work_Correctly_With_Few_Touch_Contacts() { @@ -241,14 +273,12 @@ namespace Avalonia.Input.UnitTests private static void TapOnce(IInputManager inputManager, TouchDevice device, IInputRoot root, ulong timestamp = 0, long touchPointId = 0) { - inputManager.ProcessInput(new RawPointerEventArgs(device, timestamp, - root, - RawPointerEventType.TouchBegin, - new Point(0, 0), - RawInputModifiers.None) - { - RawPointerId = touchPointId - }); + Press(inputManager, device, root, timestamp, touchPointId); + Release(inputManager, device, root, timestamp, touchPointId); + } + + private static void Release(IInputManager inputManager, TouchDevice device, IInputRoot root, ulong timestamp = 0, long touchPointId = 0) + { inputManager.ProcessInput(new RawPointerEventArgs(device, timestamp, root, RawPointerEventType.TouchEnd, @@ -258,5 +288,22 @@ namespace Avalonia.Input.UnitTests RawPointerId = touchPointId }); } + + private static void Press(IInputManager inputManager, TouchDevice device, IInputRoot root, ulong timestamp = 0, long touchPointId = 0) + { + inputManager.ProcessInput(new RawPointerEventArgs(device, timestamp, + root, + RawPointerEventType.TouchBegin, + new Point(0, 0), + RawInputModifiers.None) + { + RawPointerId = touchPointId + }); + } + + private class TestTopLevel(ITopLevelImpl impl) : TopLevel(impl) + { + + } } } From 5ba86721dedc565f88037fbcec79ee2bfa32403e Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Mon, 30 Mar 2026 23:04:58 +0200 Subject: [PATCH 38/55] Change DoDragDropAsync trigger event to PointerPressedEventArgs (#20988) * Change DnD trigger event to PointerPressedEventArgs * Update API suppressions * Switch IPlatformDragSource to a private API * Update API suppressions --- api/Avalonia.nupkg.xml | 300 ++++++++++-------- src/Avalonia.Base/Input/DragDrop.cs | 2 +- .../Input/Platform/IPlatformDragSource.cs | 4 +- .../Platform/InProcessDragSource.cs | 2 +- .../AvaloniaNativeDragSource.cs | 2 +- src/Windows/Avalonia.Win32/DragSource.cs | 2 +- 6 files changed, 174 insertions(+), 138 deletions(-) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index a63ac3baae..cbaf782d24 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -247,6 +247,24 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0001 + T:Avalonia.Controls.AppBarButton + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0001 + T:Avalonia.Controls.AppBarSeparator + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0001 + T:Avalonia.Controls.AppBarToggleButton + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0001 T:Avalonia.Controls.ApplicationLifetimes.ClassicDesktopStyleApplicationLifetimeOptions @@ -727,6 +745,24 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0001 + T:Avalonia.Controls.AppBarButton + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0001 + T:Avalonia.Controls.AppBarSeparator + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0001 + T:Avalonia.Controls.AppBarToggleButton + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0001 T:Avalonia.Controls.ApplicationLifetimes.ClassicDesktopStyleApplicationLifetimeOptions @@ -1117,6 +1153,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.DragDrop.DoDragDropAsync(Avalonia.Input.PointerEventArgs,Avalonia.Input.IDataTransfer,Avalonia.Input.DragDropEffects) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.DragEventArgs.#ctor(Avalonia.Interactivity.RoutedEvent{Avalonia.Input.DragEventArgs},Avalonia.Input.IDataObject,Avalonia.Interactivity.Interactive,Avalonia.Point,Avalonia.Input.KeyModifiers) @@ -1417,6 +1459,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.Platform.IPlatformDragSource.DoDragDropAsync(Avalonia.Input.PointerEventArgs,Avalonia.Input.IDataTransfer,Avalonia.Input.DragDropEffects) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.Raw.RawDragEvent.#ctor(Avalonia.Input.Raw.IDragDropDevice,Avalonia.Input.Raw.RawDragEventType,Avalonia.Input.IInputRoot,Avalonia.Point,Avalonia.Input.IDataObject,Avalonia.Input.DragDropEffects,Avalonia.Input.RawInputModifiers) @@ -1885,6 +1933,24 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + F:Avalonia.Controls.NavigationPage.IsBackButtonEffectivelyVisibleProperty + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + F:Avalonia.Controls.PipsPager.NextButtonStyleProperty + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + F:Avalonia.Controls.PipsPager.PreviousButtonStyleProperty + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty @@ -2119,12 +2185,42 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.NavigationPage.get_IsBackButtonEffectivelyVisible + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.PageSelectionChangedEventArgs.#ctor(Avalonia.Controls.Page,Avalonia.Controls.Page) baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.PipsPager.get_NextButtonStyle + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.get_PreviousButtonStyle + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.set_NextButtonStyle(Avalonia.Styling.ControlTheme) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.set_PreviousButtonStyle(Avalonia.Styling.ControlTheme) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Platform.DefaultMenuInteractionHandler.GotFocus(System.Object,Avalonia.Input.GotFocusEventArgs) @@ -2815,6 +2911,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.DragDrop.DoDragDropAsync(Avalonia.Input.PointerEventArgs,Avalonia.Input.IDataTransfer,Avalonia.Input.DragDropEffects) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.DragEventArgs.#ctor(Avalonia.Interactivity.RoutedEvent{Avalonia.Input.DragEventArgs},Avalonia.Input.IDataObject,Avalonia.Interactivity.Interactive,Avalonia.Point,Avalonia.Input.KeyModifiers) @@ -3115,6 +3217,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Input.Platform.IPlatformDragSource.DoDragDropAsync(Avalonia.Input.PointerEventArgs,Avalonia.Input.IDataTransfer,Avalonia.Input.DragDropEffects) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Input.Raw.RawDragEvent.#ctor(Avalonia.Input.Raw.IDragDropDevice,Avalonia.Input.Raw.RawDragEventType,Avalonia.Input.IInputRoot,Avalonia.Point,Avalonia.Input.IDataObject,Avalonia.Input.DragDropEffects,Avalonia.Input.RawInputModifiers) @@ -3583,6 +3691,24 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + F:Avalonia.Controls.NavigationPage.IsBackButtonEffectivelyVisibleProperty + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + F:Avalonia.Controls.PipsPager.NextButtonStyleProperty + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + F:Avalonia.Controls.PipsPager.PreviousButtonStyleProperty + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty @@ -3817,12 +3943,42 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.NavigationPage.get_IsBackButtonEffectivelyVisible + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.PageSelectionChangedEventArgs.#ctor(Avalonia.Controls.Page,Avalonia.Controls.Page) baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.PipsPager.get_NextButtonStyle + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.get_PreviousButtonStyle + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.set_NextButtonStyle(Avalonia.Styling.ControlTheme) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + + CP0002 + M:Avalonia.Controls.PipsPager.set_PreviousButtonStyle(Avalonia.Styling.ControlTheme) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Platform.DefaultMenuInteractionHandler.GotFocus(System.Object,Avalonia.Input.GotFocusEventArgs) @@ -4471,6 +4627,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0006 + M:Avalonia.Input.Platform.IPlatformDragSource.DoDragDropAsync(Avalonia.Input.PointerPressedEventArgs,Avalonia.Input.IDataTransfer,Avalonia.Input.DragDropEffects) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0006 M:Avalonia.Platform.ICursorFactory.CreateCursor(Avalonia.Media.Imaging.Bitmap,Avalonia.PixelPoint) @@ -4819,6 +4981,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0006 + M:Avalonia.Input.Platform.IPlatformDragSource.DoDragDropAsync(Avalonia.Input.PointerPressedEventArgs,Avalonia.Input.IDataTransfer,Avalonia.Input.DragDropEffects) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0006 M:Avalonia.Platform.ICursorFactory.CreateCursor(Avalonia.Media.Imaging.Bitmap,Avalonia.PixelPoint) @@ -5725,136 +5893,4 @@ baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll - - CP0002 - F:Avalonia.Controls.NavigationPage.IsBackButtonEffectivelyVisibleProperty - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.NavigationPage.get_IsBackButtonEffectivelyVisible - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0002 - F:Avalonia.Controls.PipsPager.PreviousButtonStyleProperty - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0002 - F:Avalonia.Controls.PipsPager.NextButtonStyleProperty - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.PipsPager.get_PreviousButtonStyle - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.PipsPager.set_PreviousButtonStyle(Avalonia.Styling.ControlTheme) - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.PipsPager.get_NextButtonStyle - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.PipsPager.set_NextButtonStyle(Avalonia.Styling.ControlTheme) - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0002 - F:Avalonia.Controls.NavigationPage.IsBackButtonEffectivelyVisibleProperty - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.NavigationPage.get_IsBackButtonEffectivelyVisible - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0002 - F:Avalonia.Controls.PipsPager.PreviousButtonStyleProperty - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0002 - F:Avalonia.Controls.PipsPager.NextButtonStyleProperty - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.PipsPager.get_PreviousButtonStyle - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.PipsPager.set_PreviousButtonStyle(Avalonia.Styling.ControlTheme) - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.PipsPager.get_NextButtonStyle - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0002 - M:Avalonia.Controls.PipsPager.set_NextButtonStyle(Avalonia.Styling.ControlTheme) - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0001 - T:Avalonia.Controls.AppBarButton - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0001 - T:Avalonia.Controls.AppBarSeparator - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0001 - T:Avalonia.Controls.AppBarToggleButton - baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll - current/Avalonia/lib/net10.0/Avalonia.Controls.dll - - - CP0001 - T:Avalonia.Controls.AppBarButton - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0001 - T:Avalonia.Controls.AppBarSeparator - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - - - CP0001 - T:Avalonia.Controls.AppBarToggleButton - baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll - current/Avalonia/lib/net8.0/Avalonia.Controls.dll - diff --git a/src/Avalonia.Base/Input/DragDrop.cs b/src/Avalonia.Base/Input/DragDrop.cs index 33f538c443..fa94e37b8c 100644 --- a/src/Avalonia.Base/Input/DragDrop.cs +++ b/src/Avalonia.Base/Input/DragDrop.cs @@ -127,7 +127,7 @@ namespace Avalonia.Input /// /// public static Task DoDragDropAsync( - PointerEventArgs triggerEvent, + PointerPressedEventArgs triggerEvent, IDataTransfer dataTransfer, DragDropEffects allowedEffects) { diff --git a/src/Avalonia.Base/Input/Platform/IPlatformDragSource.cs b/src/Avalonia.Base/Input/Platform/IPlatformDragSource.cs index 4ad36576ca..d5835d96b5 100644 --- a/src/Avalonia.Base/Input/Platform/IPlatformDragSource.cs +++ b/src/Avalonia.Base/Input/Platform/IPlatformDragSource.cs @@ -3,11 +3,11 @@ using Avalonia.Metadata; namespace Avalonia.Input.Platform { - [NotClientImplementable] + [PrivateApi] public interface IPlatformDragSource { Task DoDragDropAsync( - PointerEventArgs triggerEvent, + PointerPressedEventArgs triggerEvent, IDataTransfer dataTransfer, DragDropEffects allowedEffects); } diff --git a/src/Avalonia.Controls/Platform/InProcessDragSource.cs b/src/Avalonia.Controls/Platform/InProcessDragSource.cs index 1a7b719499..6a8b79eab6 100644 --- a/src/Avalonia.Controls/Platform/InProcessDragSource.cs +++ b/src/Avalonia.Controls/Platform/InProcessDragSource.cs @@ -31,7 +31,7 @@ namespace Avalonia.Platform } public async Task DoDragDropAsync( - PointerEventArgs triggerEvent, + PointerPressedEventArgs triggerEvent, IDataTransfer dataTransfer, DragDropEffects allowedEffects) { diff --git a/src/Avalonia.Native/AvaloniaNativeDragSource.cs b/src/Avalonia.Native/AvaloniaNativeDragSource.cs index 662d79d1b8..5927397ab5 100644 --- a/src/Avalonia.Native/AvaloniaNativeDragSource.cs +++ b/src/Avalonia.Native/AvaloniaNativeDragSource.cs @@ -33,7 +33,7 @@ namespace Avalonia.Native } public Task DoDragDropAsync( - PointerEventArgs triggerEvent, + PointerPressedEventArgs triggerEvent, IDataTransfer dataTransfer, DragDropEffects allowedEffects) { diff --git a/src/Windows/Avalonia.Win32/DragSource.cs b/src/Windows/Avalonia.Win32/DragSource.cs index f1cdfa440a..9ca295a904 100644 --- a/src/Windows/Avalonia.Win32/DragSource.cs +++ b/src/Windows/Avalonia.Win32/DragSource.cs @@ -10,7 +10,7 @@ namespace Avalonia.Win32 internal sealed class DragSource : IPlatformDragSource { public Task DoDragDropAsync( - PointerEventArgs triggerEvent, + PointerPressedEventArgs triggerEvent, IDataTransfer dataTransfer, DragDropEffects allowedEffects) { From 02d17c78636decb76d5eb3058713657c0f305e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Mon, 30 Mar 2026 22:32:22 +0200 Subject: [PATCH 39/55] Apply optimizations in Avalonia iOS (#20969) * Apply optimizations on Avalonia iOS * Updated benchmarks * More changes * Removed iOS Benchmarks project --- src/iOS/Avalonia.iOS/DispatcherImpl.cs | 23 +++++++--------------- src/iOS/Avalonia.iOS/InputHandler.cs | 4 ++-- src/iOS/Avalonia.iOS/TextInputResponder.cs | 3 ++- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/iOS/Avalonia.iOS/DispatcherImpl.cs b/src/iOS/Avalonia.iOS/DispatcherImpl.cs index b39ba1a85a..1bf4503af8 100644 --- a/src/iOS/Avalonia.iOS/DispatcherImpl.cs +++ b/src/iOS/Avalonia.iOS/DispatcherImpl.cs @@ -22,7 +22,8 @@ internal class DispatcherImpl : IDispatcherImplWithExplicitBackgroundProcessing private readonly IntPtr _mainLoop; private readonly IntPtr _mainQueue; private Thread? _loopThread; - private bool _backgroundProcessingRequested, _signaled; + private bool _backgroundProcessingRequested; + private int _signaled; private unsafe DispatcherImpl() { @@ -60,15 +61,12 @@ internal class DispatcherImpl : IDispatcherImplWithExplicitBackgroundProcessing public unsafe void Signal() { - lock (_sync) + if (Interlocked.CompareExchange(ref _signaled, 1, 0) != 0) { - if (_signaled) - return; - _signaled = true; - - Interop.dispatch_async_f(_mainQueue, IntPtr.Zero, &CheckSignaled); - Interop.CFRunLoopWakeUp(_mainLoop); + return; } + Interop.dispatch_async_f(_mainQueue, IntPtr.Zero, &CheckSignaled); + Interop.CFRunLoopWakeUp(_mainLoop); } public void UpdateTimer(long? dueTimeInMs) @@ -91,14 +89,7 @@ internal class DispatcherImpl : IDispatcherImplWithExplicitBackgroundProcessing private void CheckSignaled() { - bool signaled; - lock (_sync) - { - signaled = _signaled; - _signaled = false; - } - - if (signaled) + if (Interlocked.Exchange(ref _signaled, 0) != 0) { Signaled?.Invoke(); } diff --git a/src/iOS/Avalonia.iOS/InputHandler.cs b/src/iOS/Avalonia.iOS/InputHandler.cs index bd66d5a51e..06542cbb0d 100644 --- a/src/iOS/Avalonia.iOS/InputHandler.cs +++ b/src/iOS/Avalonia.iOS/InputHandler.cs @@ -308,7 +308,7 @@ internal sealed class InputHandler _tl.Input?.Invoke(new RawMouseWheelEventArgs( _mouseDevice, - (ulong)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), + (ulong)Environment.TickCount64, Root, _cachedScrollLocation ?? new Point(0, 0), new Vector(deltaX, deltaY), @@ -372,7 +372,7 @@ internal sealed class InputHandler // until the inertia stops. _tl.Input?.Invoke(new RawMouseWheelEventArgs( _mouseDevice, - (ulong)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), + (ulong)Environment.TickCount64, Root, _cachedScrollLocation.Value, new Vector(_momentumVelocityX, _momentumVelocityY), diff --git a/src/iOS/Avalonia.iOS/TextInputResponder.cs b/src/iOS/Avalonia.iOS/TextInputResponder.cs index 678fac8766..b2c1d1700c 100644 --- a/src/iOS/Avalonia.iOS/TextInputResponder.cs +++ b/src/iOS/Avalonia.iOS/TextInputResponder.cs @@ -84,6 +84,7 @@ partial class AvaloniaView private int _inSurroundingTextUpdateEvent; private readonly UITextPosition _beginningOfDocument = new AvaloniaTextPosition(0); private readonly UITextInputStringTokenizer _tokenizer; + private readonly NSString _textInputContextIdentifier = new NSString(Guid.NewGuid().ToString()); private bool _isInUpdate; public TextInputMethodClient? Client => _client; @@ -95,7 +96,7 @@ partial class AvaloniaView public override UIEditingInteractionConfiguration EditingInteractionConfiguration => UIEditingInteractionConfiguration.Default; - public override NSString TextInputContextIdentifier => new NSString(Guid.NewGuid().ToString()); + public override NSString TextInputContextIdentifier => _textInputContextIdentifier; public override UITextInputMode TextInputMode { From 0842236630eff07498bf306655eb6c35d8eba761 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Tue, 31 Mar 2026 13:48:55 +0200 Subject: [PATCH 40/55] Do not use visual children enumerator in TopLevelHost (#21048) * Add failing test for ExtendClientAreaToDecorationsHint in AttachedToVisualTree * Do not use visual children enumerator in TopLevelHost --- src/Avalonia.Controls/TopLevelHost.cs | 18 ++++++++---- .../WindowTests.cs | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/Avalonia.Controls/TopLevelHost.cs b/src/Avalonia.Controls/TopLevelHost.cs index 9592dd8221..afc4d59e7b 100644 --- a/src/Avalonia.Controls/TopLevelHost.cs +++ b/src/Avalonia.Controls/TopLevelHost.cs @@ -51,11 +51,14 @@ internal partial class TopLevelHost : Control var hasInset = inset != default; var desiredSize = default(Size); - foreach (var child in VisualChildren) + var children = VisualChildren; + var childrenCount = children.Count; + + for (var i = 0; i < childrenCount; i++) { - if (child is Layoutable l) + if (children[i] is Layoutable l) { - if (hasInset && ReferenceEquals(child, _topLevel)) + if (hasInset && ReferenceEquals(l, _topLevel)) { // In forced mode, measure the TopLevel with reduced size var contentSize = new Size( @@ -89,11 +92,14 @@ internal partial class TopLevelHost : Control var inset = _decorationInset; var hasInset = inset != default; - foreach (var child in VisualChildren) + var children = VisualChildren; + var childrenCount = children.Count; + + for (var i = 0; i < childrenCount; i++) { - if (child is Layoutable l) + if (children[i] is Layoutable l) { - if (hasInset && ReferenceEquals(child, _topLevel)) + if (hasInset && ReferenceEquals(l, _topLevel)) { // In forced mode, arrange the TopLevel within the inset area var contentSize = new Size( diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index 63cc2db193..3b2386b1d5 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -710,6 +710,35 @@ namespace Avalonia.Controls.UnitTests Assert.False(visualRoot.HasMirrorTransform); } + [Fact] + public void Extending_Client_Area_To_Decorations_When_Attached_To_Visual_Tree_Works() + { + var extended = false; + + var windowImpl = MockWindowingPlatform.CreateWindowMock(); + windowImpl.Setup(w => w.NeedsManagedDecorations).Returns(() => extended); + windowImpl.Setup(w => w.RequestedDrawnDecorations).Returns(PlatformRequestedDrawnDecoration.TitleBar); + + using var app = UnitTestApplication.Start(TestServices.StyledWindow.With( + windowingPlatform: new MockWindowingPlatform(() => windowImpl.Object))); + + var border = new Border(); + + var window = new Window + { + Content = border + }; + + border.AttachedToVisualTree += + (_, _) => + { + extended = true; + windowImpl.Object.ExtendClientAreaToDecorationsChanged?.Invoke(true); + }; + + window.Show(); + } + public class SizingTests : ScopedTestBase { [Fact] From 412dca3aaeff60515e003b2842b88070f98719bb Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Tue, 31 Mar 2026 16:49:29 +0500 Subject: [PATCH 41/55] Expose allowed titlebar button actions from the platform implementation and respect them from drawn decorations (#21035) * Expose WM window action capabilities from X11. * Hide titlebar buttons for unsupported actions * Simplify subscription * Explose NetSupported property from X11Globals.cs, so it's easier to make checks from the rest of the codebase * shifts * comma --- .../Chrome/WindowDrawnDecorations.cs | 53 +++++++++++++++---- src/Avalonia.Controls/Platform/IWindowImpl.cs | 10 ++++ .../Platform/PlatformAllowedWindowActions.cs | 33 ++++++++++++ src/Avalonia.Controls/Window.cs | 16 ++++++ .../Controls/WindowDrawnDecorations.xaml | 14 +++++ .../Controls/WindowDrawnDecorations.xaml | 14 +++++ src/Avalonia.X11/X11Atoms.cs | 4 ++ src/Avalonia.X11/X11Globals.cs | 25 +++++++-- src/Avalonia.X11/X11Window.cs | 30 +++++++++++ .../X11WindowModes/DefaultWindowMode.cs | 3 +- 10 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 src/Avalonia.Controls/Platform/PlatformAllowedWindowActions.cs diff --git a/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs b/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs index 7a282bf306..26dffa8f5d 100644 --- a/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs +++ b/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs @@ -1,6 +1,7 @@ using System; using Avalonia.Automation; using Avalonia.Controls.Metadata; +using Avalonia.Controls.Platform; using Avalonia.Controls.Primitives; using Avalonia.Layout; using Avalonia.LogicalTree; @@ -15,7 +16,8 @@ namespace Avalonia.Controls.Chrome; /// TopLevelHost extracts overlay/underlay/popover visuals from the template content /// and inserts them into its own visual tree. /// -[PseudoClasses(pcNormal, pcMaximized, pcFullscreen, pcHasShadow, pcHasBorder, pcHasTitlebar)] +[PseudoClasses(pcNormal, pcMaximized, pcFullscreen, pcHasShadow, pcHasBorder, pcHasTitlebar, + pcHasMaximize, pcHasFullscreen, pcHasMinimize)] [TemplatePart(PART_CloseButton, typeof(Button))] [TemplatePart(PART_MinimizeButton, typeof(Button))] [TemplatePart(PART_MaximizeButton, typeof(Button))] @@ -31,6 +33,9 @@ public class WindowDrawnDecorations : StyledElement internal const string pcHasShadow = ":has-shadow"; internal const string pcHasBorder = ":has-border"; internal const string pcHasTitlebar = ":has-titlebar"; + internal const string pcHasMaximize = ":has-maximize"; + internal const string pcHasFullscreen = ":has-fullscreen"; + internal const string pcHasMinimize = ":has-minimize"; // Template part names for caption buttons internal const string PART_CloseButton = "PART_CloseButton"; @@ -383,8 +388,11 @@ public class WindowDrawnDecorations : StyledElement Detach(); _hostWindow = window; + window.AllowedWindowActionsChanged += OnAllowedWindowActionsChanged; + _windowSubscriptions = new CompositeDisposable { + Disposable.Create(() => window.AllowedWindowActionsChanged -= OnAllowedWindowActionsChanged), window.GetObservable(Window.TitleProperty) .Subscribe(title => SetCurrentValue(TitleProperty, title)), window.GetObservable(Window.CanMaximizeProperty) @@ -407,6 +415,7 @@ public class WindowDrawnDecorations : StyledElement }), }; + UpdateAllowedActionsPseudoClasses(); UpdateMaximizeButtonState(); UpdateMinimizeButtonState(); UpdateFullScreenButtonState(); @@ -547,32 +556,54 @@ public class WindowDrawnDecorations : StyledElement e.Handled = true; } + private PlatformAllowedWindowActions EffectiveAllowedActions => + _hostWindow?.AllowedWindowActions ?? PlatformAllowedWindowActions.All; + private void UpdateMaximizeButtonState() { if (_maximizeButton == null) return; - _maximizeButton.IsEnabled = _hostWindow?.WindowState switch - { - WindowState.Maximized or WindowState.FullScreen => _hostWindow.CanResize, - WindowState.Normal => _hostWindow.CanMaximize, - _ => true - }; + _maximizeButton.IsEnabled = EffectiveAllowedActions.HasFlag(PlatformAllowedWindowActions.Maximize) + && (_hostWindow?.WindowState switch + { + WindowState.Maximized or WindowState.FullScreen => _hostWindow.CanResize, + WindowState.Normal => _hostWindow.CanMaximize, + _ => true + }); } private void UpdateMinimizeButtonState() { if (_minimizeButton == null) return; - _minimizeButton.IsEnabled = _hostWindow?.CanMinimize ?? true; + _minimizeButton.IsEnabled = EffectiveAllowedActions.HasFlag(PlatformAllowedWindowActions.Minimize) + && (_hostWindow?.CanMinimize ?? true); } private void UpdateFullScreenButtonState() { if (_fullScreenButton == null) return; - _fullScreenButton.IsEnabled = _hostWindow?.WindowState == WindowState.FullScreen - ? _hostWindow.CanResize - : _hostWindow?.CanMaximize ?? true; + _fullScreenButton.IsEnabled = EffectiveAllowedActions.HasFlag(PlatformAllowedWindowActions.Fullscreen) + && (_hostWindow?.WindowState == WindowState.FullScreen + ? _hostWindow.CanResize + : _hostWindow?.CanMaximize ?? true); + } + + private void OnAllowedWindowActionsChanged(PlatformAllowedWindowActions actions) + { + UpdateAllowedActionsPseudoClasses(); + UpdateMaximizeButtonState(); + UpdateMinimizeButtonState(); + UpdateFullScreenButtonState(); + } + + private void UpdateAllowedActionsPseudoClasses() + { + var actions = EffectiveAllowedActions; + PseudoClasses.Set(pcHasMaximize, actions.HasFlag(PlatformAllowedWindowActions.Maximize)); + PseudoClasses.Set(pcHasFullscreen, actions.HasFlag(PlatformAllowedWindowActions.Fullscreen)); + PseudoClasses.Set(pcHasMinimize, actions.HasFlag(PlatformAllowedWindowActions.Minimize)); } private void UpdateEffectiveGeometry() diff --git a/src/Avalonia.Controls/Platform/IWindowImpl.cs b/src/Avalonia.Controls/Platform/IWindowImpl.cs index 42be775f0d..d864ca35c3 100644 --- a/src/Avalonia.Controls/Platform/IWindowImpl.cs +++ b/src/Avalonia.Controls/Platform/IWindowImpl.cs @@ -162,5 +162,15 @@ namespace Avalonia.Platform /// /// -1 for platform default, otherwise the height in DIPs. void SetExtendClientAreaTitleBarHeightHint(double titleBarHeight); + + /// + /// Gets the window actions that the underlying platform currently allows. + /// + PlatformAllowedWindowActions AllowedWindowActions => PlatformAllowedWindowActions.All; + + /// + /// Gets or sets a callback invoked when changes. + /// + Action? AllowedWindowActionsChanged { get => null; set { } } } } diff --git a/src/Avalonia.Controls/Platform/PlatformAllowedWindowActions.cs b/src/Avalonia.Controls/Platform/PlatformAllowedWindowActions.cs new file mode 100644 index 0000000000..35871c0803 --- /dev/null +++ b/src/Avalonia.Controls/Platform/PlatformAllowedWindowActions.cs @@ -0,0 +1,33 @@ +using System; +using Avalonia.Metadata; + +namespace Avalonia.Controls.Platform; + +/// +/// Flags indicating which window actions the underlying platform supports. +/// +[Flags, PrivateApi] +public enum PlatformAllowedWindowActions +{ + None = 0, + + /// + /// The underlying platform supports maximizing/unmaximizing windows. + /// + Maximize = 1 << 0, + + /// + /// The underlying platform supports fullscreen mode. + /// + Fullscreen = 1 << 1, + + /// + /// The underlying platform supports minimizing windows. + /// + Minimize = 1 << 2, + + /// + /// All actions are supported (default when the underlying platform does not report capabilities). + /// + All = Maximize | Fullscreen | Minimize, +} diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 4dab3574eb..0797f39733 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -220,6 +220,7 @@ namespace Avalonia.Controls private bool _positionWasSet; private bool _wasShownBefore; private IDisposable? _modalSubscription; + private PlatformAllowedWindowActions _allowedWindowActions = PlatformAllowedWindowActions.All; /// /// Initializes static members of the class. @@ -250,6 +251,8 @@ namespace Avalonia.Controls impl.WindowStateChanged = HandleWindowStateChanged; _maxPlatformClientSize = PlatformImpl?.MaxAutoSizeHint ?? default(Size); impl.ExtendClientAreaToDecorationsChanged = ExtendClientAreaToDecorationsChanged; + impl.AllowedWindowActionsChanged = OnAllowedWindowActionsChanged; + _allowedWindowActions = impl.AllowedWindowActions; this.GetObservable(ClientSizeProperty).Skip(1).Subscribe(x => { ResizePlatformImpl(x, WindowResizeReason.Application); @@ -485,6 +488,11 @@ namespace Avalonia.Controls set => SetValue(CanMaximizeProperty, value); } + /// + /// Gets the window actions currently allowed by the underlying platform. + /// + internal PlatformAllowedWindowActions AllowedWindowActions => _allowedWindowActions; + /// /// Gets or sets the icon of the window. /// @@ -673,6 +681,14 @@ namespace Avalonia.Controls UpdateDrawnDecorationParts(); } + internal event Action? AllowedWindowActionsChanged; + + private void OnAllowedWindowActionsChanged(PlatformAllowedWindowActions actions) + { + _allowedWindowActions = actions; + AllowedWindowActionsChanged?.Invoke(actions); + } + private void ExtendClientAreaToDecorationsChanged(bool isExtended) { IsExtendedIntoWindowDecorations = isExtended; diff --git a/src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml b/src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml index b2d66fdbbd..3ccda42ce3 100644 --- a/src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/WindowDrawnDecorations.xaml @@ -196,6 +196,20 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml b/src/Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml index c9ca5ebc63..4d8c4b0d90 100644 --- a/src/Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml +++ b/src/Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml @@ -1,6 +1,63 @@  - 32 + 24 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + - + + + + diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index fa578a7afe..9d7b8a6118 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -1537,11 +1537,11 @@ namespace Avalonia.Controls.UnitTests } [Theory] - [InlineData(2,4)] - [InlineData(0,4)] - [InlineData(2,6)] - [InlineData(0,6)] - [InlineData(3,4)] + [InlineData(2, 4)] + [InlineData(0, 4)] + [InlineData(2, 6)] + [InlineData(0, 6)] + [InlineData(3, 4)] public void When_Selection_From_Left_To_Right_Pressing_Right_Should_Remove_Selection_Moving_Caret_To_End_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1566,11 +1566,11 @@ namespace Avalonia.Controls.UnitTests } [Theory] - [InlineData(2,4)] - [InlineData(0,4)] - [InlineData(2,6)] - [InlineData(0,6)] - [InlineData(3,4)] + [InlineData(2, 4)] + [InlineData(0, 4)] + [InlineData(2, 6)] + [InlineData(0, 6)] + [InlineData(3, 4)] public void When_Selection_From_Left_To_Right_Pressing_Left_Should_Remove_Selection_Moving_Caret_To_Start_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1595,11 +1595,11 @@ namespace Avalonia.Controls.UnitTests } [Theory] - [InlineData(4,2)] - [InlineData(4,0)] - [InlineData(6,2)] - [InlineData(6,0)] - [InlineData(4,3)] + [InlineData(4, 2)] + [InlineData(4, 0)] + [InlineData(6, 2)] + [InlineData(6, 0)] + [InlineData(4, 3)] public void When_Selection_From_Right_To_Left_Pressing_Right_Should_Remove_Selection_Moving_Caret_To_Start_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1624,11 +1624,11 @@ namespace Avalonia.Controls.UnitTests } [Theory] - [InlineData(4,2)] - [InlineData(4,0)] - [InlineData(6,2)] - [InlineData(6,0)] - [InlineData(4,3)] + [InlineData(4, 2)] + [InlineData(4, 0)] + [InlineData(6, 2)] + [InlineData(6, 0)] + [InlineData(4, 3)] public void When_Selection_From_Right_To_Left_Pressing_Left_Should_Remove_Selection_Moving_Caret_To_End_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1707,11 +1707,11 @@ namespace Avalonia.Controls.UnitTests } [Theory] - [InlineData(2,4)] - [InlineData(0,4)] - [InlineData(2,6)] - [InlineData(0,6)] - [InlineData(3,4)] + [InlineData(2, 4)] + [InlineData(0, 4)] + [InlineData(2, 6)] + [InlineData(0, 6)] + [InlineData(3, 4)] public void When_Selection_From_Left_To_Right_Pressing_Up_Should_Remove_Selection_Moving_Caret_To_Start_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1736,11 +1736,11 @@ namespace Avalonia.Controls.UnitTests } [Theory] - [InlineData(4,2)] - [InlineData(4,0)] - [InlineData(6,2)] - [InlineData(6,0)] - [InlineData(4,3)] + [InlineData(4, 2)] + [InlineData(4, 0)] + [InlineData(6, 2)] + [InlineData(6, 0)] + [InlineData(4, 3)] public void When_Selection_From_Right_To_Left_Pressing_Up_Should_Remove_Selection_Moving_Caret_To_End_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1792,11 +1792,11 @@ namespace Avalonia.Controls.UnitTests } [Theory] - [InlineData(2,4)] - [InlineData(0,4)] - [InlineData(2,6)] - [InlineData(0,6)] - [InlineData(3,4)] + [InlineData(2, 4)] + [InlineData(0, 4)] + [InlineData(2, 6)] + [InlineData(0, 6)] + [InlineData(3, 4)] public void When_Selection_From_Left_To_Right_Pressing_Down_Should_Remove_Selection_Moving_Caret_To_End_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) @@ -1821,11 +1821,11 @@ namespace Avalonia.Controls.UnitTests } [Theory] - [InlineData(4,2)] - [InlineData(4,0)] - [InlineData(6,2)] - [InlineData(6,0)] - [InlineData(4,3)] + [InlineData(4, 2)] + [InlineData(4, 0)] + [InlineData(6, 2)] + [InlineData(6, 0)] + [InlineData(4, 3)] public void When_Selection_From_Right_To_Left_Pressing_Down_Should_Remove_Selection_Moving_Caret_To_Start_Of_Previous_Selection(int selectionStart, int selectionEnd) { using (UnitTestApplication.Start(Services)) diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests_Input.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests_Input.cs new file mode 100644 index 0000000000..e19bc5ca9a --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests_Input.cs @@ -0,0 +1,278 @@ +#nullable enable + +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Harfbuzz; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Platform; +using Avalonia.Threading; +using Avalonia.UnitTests; +using Moq; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class TextBoxTests_Input : ScopedTestBase + { + [Fact] + public void Touch_Tap_Moves_Caret() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + Template = CreateTemplate(), + Text = "12 12345678" + }; + + var root = new TestRoot() + { + Child = target + }; + + target.ApplyTemplate(); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + var touch = new TouchTestHelper(); + + Assert.Equal(target.CaretIndex, 0); + + // Move to index 8 + touch.Down(target, new Point(50, 0)); + touch.Up(target, new Point(50, 0)); + + Assert.Equal(target.CaretIndex, 8); + } + } + + [Fact] + public void Touch_Double_Tap_Selects_Word() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + Template = CreateTemplate(), + Text = "12 12345678" + }; + + var root = new TestRoot() + { + Child = target + }; + + target.ApplyTemplate(); + + + root.LayoutManager.ExecuteInitialLayoutPass(); + + var touch = new TouchTestHelper(); + + Assert.Equal(target.CaretIndex, 0); + + // Move to index 8 + touch.Down(target, new Point(50, 0)); + touch.Up(target, new Point(50, 0)); + + // Double tap + touch.Down(target, new Point(50, 0)); + touch.Up(target, new Point(50, 0)); + + Assert.Equal(target.SelectionStart, 3); + Assert.Equal(target.SelectionEnd, 11); + Assert.Equal(target.CaretIndex, 8); + } + } + + [Fact] + public void Touch_Hold_Selects_Word() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + Template = CreateTemplate(), + Text = "12 12345678" + }; + + var root = new TestRoot() + { + Child = target + }; + + target.ApplyTemplate(); + + root.LayoutManager.ExecuteInitialLayoutPass(); + + var touch = new TouchTestHelper(); + + Assert.Equal(target.CaretIndex, 0); + + // Move to index 8 + touch.Down(target, new Point(50, 0)); + + var timer = Assert.Single(Dispatcher.SnapshotTimersForUnitTests()); + timer.ForceFire(); + touch.Up(target, new Point(50, 0)); + + Assert.Equal(target.SelectionStart, 3); + Assert.Equal(target.SelectionEnd, 11); + Assert.Equal(target.CaretIndex, 8); + } + } + + [Fact] + public void Touch_Hold_On_Selection_Requests_Context() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + Template = CreateTemplate(), + Text = "12 12345678" + }; + + var root = new TestRoot() + { + Child = target + }; + + target.ApplyTemplate(); + bool requested = false; + + target.SelectionStart = 3; + target.SelectionEnd = 11; + + target.ContextRequested += Target_ContextRequested; + + root.LayoutManager.ExecuteInitialLayoutPass(); + + var touch = new TouchTestHelper(); + + Assert.Equal(target.CaretIndex, 0); + + // Move to index 8 + touch.Down(target, new Point(50, 0)); + + var timer = Assert.Single(Dispatcher.SnapshotTimersForUnitTests()); + timer.ForceFire(); + touch.Up(target, new Point(50, 0)); + + Assert.True(requested); + + void Target_ContextRequested(object? sender, ContextRequestedEventArgs e) + { + requested = true; + } + } + } + + private static TestServices Services => TestServices.MockThreadingInterface.With( + standardCursorFactory: Mock.Of(), + renderInterface: new HeadlessPlatformRenderInterface(), + textShaperImpl: new HarfBuzzTextShaper(), + fontManagerImpl: new TestFontManager(), + assetLoader: new StandardAssetLoader()); + + internal static IControlTemplate CreateTemplate() + { + return new FuncControlTemplate((control, scope) => + new ScrollViewer + { + Name = "PART_ScrollViewer", + Template = new FuncControlTemplate(ScrollViewerTests.CreateTemplate), + Content = new TextPresenter + { + Name = "PART_TextPresenter", + [!!TextPresenter.TextProperty] = new Binding + { + Path = nameof(TextPresenter.Text), + Mode = BindingMode.TwoWay, + Priority = BindingPriority.Template, + RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent), + }, + [!!TextPresenter.CaretIndexProperty] = new Binding + { + Path = nameof(TextPresenter.CaretIndex), + Mode = BindingMode.TwoWay, + Priority = BindingPriority.Template, + RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent), + } + }.RegisterInNameScope(scope) + }.RegisterInNameScope(scope)); + } + + private static void RaiseKeyEvent(TextBox textBox, Key key, KeyModifiers inputModifiers) + { + textBox.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + KeyModifiers = inputModifiers, + Key = key + }); + } + + private static void RaiseTextEvent(TextBox textBox, string text) + { + textBox.RaiseEvent(new TextInputEventArgs + { + RoutedEvent = InputElement.TextInputEvent, + Text = text + }); + } + + private class Class1 : NotifyingBase + { + private int _foo; + private string? _bar; + + public int Foo + { + get { return _foo; } + set { _foo = value; RaisePropertyChanged(); } + } + + public string? Bar + { + get { return _bar; } + set { _bar = value; RaisePropertyChanged(); } + } + } + + private class TestTopLevel(ITopLevelImpl impl) : TopLevel(impl) + { + } + + private static Mock CreateMockTopLevelImpl() + { + var clipboard = new Mock(); + clipboard.Setup(x => x.Compositor).Returns(RendererMocks.CreateDummyCompositor()); + clipboard.Setup(r => r.TryGetFeature(typeof(IClipboard))) + .Returns(new Clipboard(new HeadlessClipboardImplStub())); + clipboard.SetupGet(x => x.RenderScaling).Returns(1); + return clipboard; + } + + private static FuncControlTemplate CreateTopLevelTemplate() + { + return new FuncControlTemplate((x, scope) => + new ContentPresenter + { + Name = "PART_ContentPresenter", + [!ContentPresenter.ContentProperty] = x[!ContentControl.ContentProperty], + }.RegisterInNameScope(scope)); + } + + private class TestContextMenu : ContextMenu + { + public TestContextMenu() + { + IsOpen = true; + } + } + } +} diff --git a/tests/Avalonia.UnitTests/TouchTestHelper.cs b/tests/Avalonia.UnitTests/TouchTestHelper.cs index 3ec49fa321..7a13425997 100644 --- a/tests/Avalonia.UnitTests/TouchTestHelper.cs +++ b/tests/Avalonia.UnitTests/TouchTestHelper.cs @@ -7,6 +7,7 @@ namespace Avalonia.UnitTests { private readonly Pointer _pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Touch, true); private ulong _nextStamp = 1; + private int _clickCount = 0; private ulong Timestamp() => _nextStamp++; public IInputElement? Captured => _pointer.Captured; @@ -20,7 +21,7 @@ namespace Avalonia.UnitTests _pointer.Capture((IInputElement)target); source.RaiseEvent(new PointerPressedEventArgs(source, _pointer, (Visual)source, position, Timestamp(), new(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed), - modifiers)); + modifiers, ++_clickCount)); } public void Move(Interactive target, in Point position, KeyModifiers modifiers = default) => Move(target, target, position, modifiers); From 5d5fce8d9b1330a07cfd2ad0739180c403a91eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Su=C3=A1rez?= Date: Tue, 31 Mar 2026 16:54:40 +0200 Subject: [PATCH 46/55] Add keyboard navigation for overflow CommandBar popup (#21005) * Fix keyboard navigation in CommandBar overflow * More fixes * More changes * More changes, added more tests --- .../CommandBar/CommandBarKeyboardPage.xaml | 121 ++++++++++ .../CommandBar/CommandBarKeyboardPage.xaml.cs | 93 ++++++++ .../Pages/CommandBarPage.xaml.cs | 1 + .../CommandBar/CommandBar.cs | 128 +++++++++- .../Controls/CommandBar.xaml | 5 +- .../Controls/CommandBar.xaml | 5 +- .../CommandBarTests.cs | 223 ++++++++++++++++++ 7 files changed, 569 insertions(+), 7 deletions(-) create mode 100644 samples/ControlCatalog/Pages/CommandBar/CommandBarKeyboardPage.xaml create mode 100644 samples/ControlCatalog/Pages/CommandBar/CommandBarKeyboardPage.xaml.cs diff --git a/samples/ControlCatalog/Pages/CommandBar/CommandBarKeyboardPage.xaml b/samples/ControlCatalog/Pages/CommandBar/CommandBarKeyboardPage.xaml new file mode 100644 index 0000000000..7b820b0183 --- /dev/null +++ b/samples/ControlCatalog/Pages/CommandBar/CommandBarKeyboardPage.xaml @@ -0,0 +1,121 @@ + + + M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z + M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z + M15.6,10.79C17.04,10.07 18,8.64 18,7C18,4.79 16.21,3 14,3H7V21H14.73C16.78,21 18.5,19.37 18.5,17.32C18.5,15.82 17.72,14.53 16.5,13.77C16.2,13.59 15.9,13.44 15.6,13.32V10.79M10,6.5H13C13.83,6.5 14.5,7.17 14.5,8C14.5,8.83 13.83,9.5 13,9.5H10V6.5M13.5,17.5H10V14H13.5C14.33,14 15,14.67 15,15.5C15,16.33 14.33,17.5 13.5,17.5Z + M18,16.08C17.24,16.08 16.56,16.38 16.04,16.85L8.91,12.7C8.96,12.47 9,12.24 9,12C9,11.76 8.96,11.53 8.91,11.3L15.96,7.19C16.5,7.69 17.21,8 18,8A3,3 0 0,0 21,5A3,3 0 0,0 18,2A3,3 0 0,0 15,5C15,5.24 15.04,5.47 15.09,5.7L8.04,9.81C7.5,9.31 6.79,9 6,9A3,3 0 0,0 3,12A3,3 0 0,0 6,15C6.79,15 7.5,14.69 8.04,14.19L15.16,18.34C15.11,18.55 15.08,18.77 15.08,19C15.08,20.61 16.39,21.91 18,21.91C19.61,21.91 20.92,20.61 20.92,19C20.92,17.39 19.61,16.08 18,16.08Z + M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z + + + + + + + + + + + + + + + + - [TemplatePart("PART_OverflowButton", typeof(Button))] + [TemplatePart("PART_OverflowButton", typeof(Button))] [TemplatePart("PART_OverflowPopup", typeof(Popup))] - [TemplatePart("PART_ContentPresenter", typeof(Control))] + [TemplatePart("PART_OverflowPresenter", typeof(ItemsControl))] + [TemplatePart("PART_ContentPresenter", typeof(Control))] public class CommandBar : TemplatedControl { /// @@ -129,12 +131,14 @@ namespace Avalonia.Controls private Button? _overflowButton; private Popup? _overflowPopup; + private ItemsControl? _overflowPresenter; private Control? _contentPresenter; private readonly ObservableCollection _visiblePrimaryCommands = new(); private readonly ObservableCollection _overflowItems = new(); private bool _isDynamicUpdateInProgress; private double _constraintWidth = double.PositiveInfinity; + private bool _openedViaKeyboard; public CommandBar() { @@ -348,14 +352,33 @@ namespace Avalonia.Controls base.OnApplyTemplate(e); if (_overflowButton != null) + { _overflowButton.Click -= OnOverflowButtonClick; + _overflowButton.GotFocus -= OnOverflowButtonGotFocus; + _overflowButton.RemoveHandler(Input.InputElement.KeyDownEvent, OnOverflowButtonKeyDown); + _overflowButton.RemoveHandler(Input.InputElement.PointerPressedEvent, OnOverflowButtonPointerPressed); + } + if (_overflowPresenter != null) + _overflowPresenter.KeyDown -= OnOverflowPresenterKeyDown; + if (_overflowPopup != null) + _overflowPopup.Opened -= OnOverflowPopupOpened; _overflowButton = e.NameScope.Find