From f8a1f2e2aa1aca96f5b0bfb5c3e31ee245efcdac Mon Sep 17 00:00:00 2001 From: SunnyDesignor <50539661+SunnyDesignor@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:18:07 +0800 Subject: [PATCH 01/33] Fix unexpected crashes during accessibility actions. (#20687) --- .../Avalonia.Android/AvaloniaAccessHelper.cs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs index ac92aafc29..ca06998dbc 100644 --- a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs +++ b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs @@ -135,10 +135,30 @@ namespace Avalonia.Android protected override bool OnPerformActionForVirtualView(int virtualViewId, int action, Bundle? arguments) { return (GetNodeInfoProvidersFromVirtualViewId(virtualViewId) ?? []) - .Select(x => x.PerformNodeAction(action, arguments)) + .Select(x => TryPerformNodeAction(x, action, arguments)) .Aggregate(false, (a, b) => a | b); } + private static bool TryPerformNodeAction(INodeInfoProvider nodeInfoProvider, int action, Bundle? arguments) + { + try + { + return nodeInfoProvider.PerformNodeAction(action, arguments); + } + catch (ElementNotEnabledException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + } + protected override void OnPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat? nodeInfo) { if (nodeInfo is null || !_peers.TryGetValue(virtualViewId, out AutomationPeer? peer)) From 0fb0eb8d619475837eefabb444547a24f51f31b7 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 16 Feb 2026 16:21:32 +0500 Subject: [PATCH 02/33] Typo from 2021 (#20692) --- src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs b/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs index 1ea754f0f4..ef8ae00c79 100644 --- a/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs +++ b/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs @@ -24,7 +24,7 @@ namespace Avalonia.Input.TextInput if (visual != null) { visual.AttachedToVisualTree += OnAttachedToVisualTree; - visual.DetachedFromVisualTree -= OnDetachedFromVisualTree; + visual.DetachedFromVisualTree += OnDetachedFromVisualTree; if (visual.IsAttachedToVisualTree) SubscribeToParents(); UpdateMatrix(); From ab5f8fca7496ff740b29583f46f5359d3c9d7df8 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 16 Feb 2026 19:13:21 +0500 Subject: [PATCH 03/33] Make sure that adorners are actually correctly positioned in visual tree for TransformToVisual and friends (#20691) * Make sure that adorners are actually correctly positioned in visual tree for TransformToVisual and friends * Fixes --- .../Pages/AdornerLayerPage.xaml | 36 ++-- .../Input/TextInput/InputMethodManager.cs | 2 +- .../TextInput/TransformTrackingHelper.cs | 27 ++- .../ServerCompositionVisual.Adorners.cs | 17 +- .../Primitives/AdornerHelper.cs | 183 ++++++++++++++++++ .../Primitives/AdornerLayer.cs | 82 +++++--- src/Avalonia.Controls/Primitives/Popup.cs | 2 +- .../PopupPositioning/IPopupPositioner.cs | 31 +-- 8 files changed, 295 insertions(+), 85 deletions(-) create mode 100644 src/Avalonia.Controls/Primitives/AdornerHelper.cs diff --git a/samples/ControlCatalog/Pages/AdornerLayerPage.xaml b/samples/ControlCatalog/Pages/AdornerLayerPage.xaml index 7501c80940..e9a245a8e1 100644 --- a/samples/ControlCatalog/Pages/AdornerLayerPage.xaml +++ b/samples/ControlCatalog/Pages/AdornerLayerPage.xaml @@ -44,21 +44,31 @@ VerticalContentAlignment="Center" VerticalAlignment="Stretch" Width="200" Height="42"> - - - - - - + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs b/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs index ae3a8bc6a1..005a015644 100644 --- a/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs +++ b/src/Avalonia.Base/Input/TextInput/InputMethodManager.cs @@ -10,7 +10,7 @@ namespace Avalonia.Input.TextInput private IInputElement? _focusedElement; private Interactive? _visualRoot; private TextInputMethodClient? _client; - private readonly TransformTrackingHelper _transformTracker = new TransformTrackingHelper(); + private readonly TransformTrackingHelper _transformTracker = new TransformTrackingHelper(true); public TextInputMethodManager() { diff --git a/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs b/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs index ef8ae00c79..c5954bc600 100644 --- a/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs +++ b/src/Avalonia.Base/Input/TextInput/TransformTrackingHelper.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using Avalonia.Media; +using Avalonia.Reactive; using Avalonia.Threading; using Avalonia.VisualTree; @@ -7,13 +9,15 @@ namespace Avalonia.Input.TextInput { class TransformTrackingHelper : IDisposable { + private readonly bool _deferAfterRenderPass; private Visual? _visual; private bool _queuedForUpdate; private readonly EventHandler _propertyChangedHandler; private readonly List _propertyChangedSubscriptions = new List(); - public TransformTrackingHelper() + public TransformTrackingHelper(bool deferAfterRenderPass) { + _deferAfterRenderPass = deferAfterRenderPass; _propertyChangedHandler = PropertyChangedHandler; } @@ -70,6 +74,7 @@ namespace Avalonia.Input.TextInput void UpdateMatrix() { + _queuedForUpdate = false; Matrix? matrix = null; if (_visual != null && _visual.VisualRoot != null) matrix = _visual.TransformToVisual((Visual)_visual.VisualRoot); @@ -91,7 +96,10 @@ namespace Avalonia.Input.TextInput if(_queuedForUpdate) return; _queuedForUpdate = true; - Dispatcher.UIThread.Post(UpdateMatrix, DispatcherPriority.AfterRender); + if (_deferAfterRenderPass) + Dispatcher.UIThread.Post(UpdateMatrix, DispatcherPriority.AfterRender); + else + MediaContext.Instance.BeginInvokeOnRender(UpdateMatrix); } private void PropertyChangedHandler(object? sender, AvaloniaPropertyChangedEventArgs e) @@ -106,12 +114,23 @@ namespace Avalonia.Input.TextInput UpdateMatrix(); } - public static IDisposable Track(Visual visual, Action cb) + public static IDisposable Track(Visual visual, bool deferAfterRenderPass, Action cb) { - var rv = new TransformTrackingHelper(); + var rv = new TransformTrackingHelper(deferAfterRenderPass); rv.MatrixChanged += () => cb(visual, rv.Matrix); rv.SetVisual(visual); return rv; } + + public static IObservable Observe(Visual visual, bool deferAfterRenderPass) + { + return Observable.Create(observer => + { + var rv = new TransformTrackingHelper(deferAfterRenderPass); + rv.MatrixChanged += () => observer.OnNext(rv.Matrix); + rv.SetVisual(visual); + return rv; + }); + } } } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs index 6f5f7b7572..fe6effbbd4 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual/ServerCompositionVisual.Adorners.cs @@ -46,20 +46,25 @@ partial class ServerCompositionVisual public void UpdateAdorner() { GetAttHelper().EnqueuedForAdornerUpdate = false; - var ownTransform = MatrixUtils.ComputeTransform(Size, AnchorPoint, CenterPoint, TransformMatrix, Scale, - RotationAngle, Orientation, Offset); - + if (AdornedVisual != null && Parent != null) { + // We ignore Visual's RenderTransform completely since it's set by AdornerLayer and can be out of sync + // with compositor-driver animations + var ownTransform = MatrixUtils.ComputeTransform(Size, AnchorPoint, CenterPoint, Matrix.Identity, Scale, + RotationAngle, Orientation, Offset); if ( AdornerLayer_GetExpectedSharedAncestor(this) is {} sharedAncestor && ComputeTransformFromAncestor(AdornedVisual, sharedAncestor, out var adornerLayerToAdornedVisual)) - ownTransform = (ownTransform ?? Matrix.Identity) * adornerLayerToAdornedVisual; + _ownTransform = (ownTransform ?? Matrix.Identity) * adornerLayerToAdornedVisual; else - ownTransform = default(Matrix); // Don't render, something is broken + _ownTransform = default(Matrix); // Don't render, something is broken } - _ownTransform = ownTransform; + else + _ownTransform = MatrixUtils.ComputeTransform(Size, AnchorPoint, CenterPoint, TransformMatrix, Scale, + RotationAngle, Orientation, Offset); + PropagateFlags(true, true); } diff --git a/src/Avalonia.Controls/Primitives/AdornerHelper.cs b/src/Avalonia.Controls/Primitives/AdornerHelper.cs new file mode 100644 index 0000000000..c3f967da5c --- /dev/null +++ b/src/Avalonia.Controls/Primitives/AdornerHelper.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.Primitives; + +class AdornerHelper +{ + + public static IDisposable SubscribeToAncestorPropertyChanges(Visual visual, + bool includeClip, Action changed) + { + return new AncestorPropertyChangesSubscription(visual, includeClip, changed); + } + + private class AncestorPropertyChangesSubscription : IDisposable + { + private readonly Visual _visual; + private readonly bool _includeClip; + private readonly Action _changed; + private readonly EventHandler _propertyChangedHandler; + private readonly List _subscriptions = new List(); + private bool _isDisposed; + + public AncestorPropertyChangesSubscription(Visual visual, bool includeClip, Action changed) + { + _visual = visual; + _includeClip = includeClip; + _changed = changed; + _propertyChangedHandler = OnPropertyChanged; + + _visual.AttachedToVisualTree += OnAttachedToVisualTree; + _visual.DetachedFromVisualTree += OnDetachedFromVisualTree; + + if (_visual.IsAttachedToVisualTree) + { + SubscribeToAncestors(); + } + } + + private void SubscribeToAncestors() + { + UnsubscribeFromAncestors(); + + // Subscribe to the visual's own Bounds property + _visual.PropertyChanged += _propertyChangedHandler; + _subscriptions.Add(_visual); + + // Walk up the ancestor chain + var ancestor = _visual.VisualParent; + while (ancestor != null) + { + if (ancestor is Visual visualAncestor) + { + visualAncestor.PropertyChanged += _propertyChangedHandler; + _subscriptions.Add(visualAncestor); + } + ancestor = ancestor.VisualParent; + } + } + + private void UnsubscribeFromAncestors() + { + foreach (var subscription in _subscriptions) + { + subscription.PropertyChanged -= _propertyChangedHandler; + } + _subscriptions.Clear(); + } + + private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (!e.IsEffectiveValueChange) + return; + + bool shouldNotify = false; + + if (e.Property == Visual.RenderTransformProperty || e.Property == Visual.BoundsProperty) + { + shouldNotify = true; + } + else if (_includeClip) + { + if (e.Property == Visual.ClipToBoundsProperty || + e.Property == Visual.ClipProperty) shouldNotify = true; + } + + if (shouldNotify) + { + _changed(); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + SubscribeToAncestors(); + _changed(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + UnsubscribeFromAncestors(); + _changed(); + } + + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + UnsubscribeFromAncestors(); + _visual.AttachedToVisualTree -= OnAttachedToVisualTree; + _visual.DetachedFromVisualTree -= OnDetachedFromVisualTree; + } + } + + public static Geometry? CalculateAdornerClip(Visual adornedElement) + { + // Walk ancestor stack and calculate clip geometry relative to the current visual. + // If ClipToBounds = true, add extra RectangleGeometry for Bounds.Size + + Geometry? result = null; + var ancestor = adornedElement; + + while (ancestor != null) + { + if (ancestor is Visual visualAncestor) + { + Geometry? ancestorClip = null; + + // Check if ancestor has ClipToBounds enabled + if (visualAncestor.ClipToBounds) + { + ancestorClip = new RectangleGeometry(new Rect(visualAncestor.Bounds.Size)); + } + + // Check if ancestor has explicit Clip geometry + if (visualAncestor.Clip != null) + { + if (ancestorClip != null) + { + ancestorClip = new CombinedGeometry(GeometryCombineMode.Intersect, ancestorClip, visualAncestor.Clip); + } + else + { + ancestorClip = visualAncestor.Clip; + } + } + + // Transform the clip geometry to adorned element's coordinate space + if (ancestorClip != null) + { + var transform = visualAncestor.TransformToVisual(adornedElement); + if (transform.HasValue && !transform.Value.IsIdentity) + { + ancestorClip = ancestorClip.Clone(); + var matrix = ancestorClip.Transform is { Value.IsIdentity: false } + ? transform.Value * ancestorClip.Transform.Value + : transform.Value; + ancestorClip.Transform = new MatrixTransform(matrix); + } + + // Combine with existing result + if (result != null) + { + result = new CombinedGeometry(GeometryCombineMode.Intersect, result, ancestorClip); + } + else + { + result = ancestorClip; + } + } + } + + ancestor = ancestor.VisualParent; + } + + return result; + } + +} \ No newline at end of file diff --git a/src/Avalonia.Controls/Primitives/AdornerLayer.cs b/src/Avalonia.Controls/Primitives/AdornerLayer.cs index 412dd236ff..6271f16784 100644 --- a/src/Avalonia.Controls/Primitives/AdornerLayer.cs +++ b/src/Avalonia.Controls/Primitives/AdornerLayer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Specialized; +using Avalonia.Input.TextInput; using Avalonia.Media; using Avalonia.Reactive; using Avalonia.VisualTree; @@ -45,15 +46,20 @@ namespace Avalonia.Controls.Primitives private static readonly AttachedProperty s_savedAdornerLayerProperty = AvaloniaProperty.RegisterAttached("SavedAdornerLayer"); + private TransformTrackingHelper _trackingHelper = new TransformTrackingHelper(false); + static AdornerLayer() { AdornedElementProperty.Changed.Subscribe(AdornedElementChanged); AdornerProperty.Changed.Subscribe(AdornerChanged); + IsClipEnabledProperty.Changed.Subscribe(AdornerIsClipEnabledChanged); } public AdornerLayer() { Children.CollectionChanged += ChildrenCollectionChanged; + _trackingHelper.SetVisual(this); + _trackingHelper.MatrixChanged += delegate { InvalidateMeasure(); }; } public static Visual? GetAdornedElement(Visual adorner) @@ -199,9 +205,9 @@ namespace Avalonia.Controls.Primitives { var info = ao.GetValue(s_adornedElementInfoProperty); - if (info != null && info.Bounds.HasValue) + if (info is { AdornedElement: not null }) { - child.Measure(info.Bounds.Value.Bounds.Size); + child.Measure(info.AdornedElement.Bounds.Size); } else { @@ -223,12 +229,22 @@ namespace Avalonia.Controls.Primitives var info = ao.GetValue(s_adornedElementInfoProperty); var isClipEnabled = ao.GetValue(IsClipEnabledProperty); - if (info != null && info.Bounds.HasValue) + var adorned = info?.AdornedElement; + + if (adorned != null) { - child.RenderTransform = new MatrixTransform(info.Bounds.Value.Transform); + child.Arrange(new(adorned.Bounds.Size)); + var transform = adorned.TransformToVisual(this); + // If somebody decides that having Margin on an adorner is a good idea, + // we need to compensate for element being positioned at non-(0,0) coords. + if (transform != null && child.Bounds.Position != default) + { + transform = Matrix.CreateTranslation(child.Bounds.Position) * transform.Value * + Matrix.CreateTranslation(-child.Bounds.Position); + } + child.RenderTransform = new MatrixTransform(transform ?? default); child.RenderTransformOrigin = new RelativePoint(new Point(0, 0), RelativeUnit.Absolute); - UpdateClip(child, info.Bounds.Value, isClipEnabled); - child.Arrange(info.Bounds.Value.Bounds); + UpdateClip(child, adorned, isClipEnabled); } else { @@ -248,29 +264,22 @@ namespace Avalonia.Controls.Primitives layer?.UpdateAdornedElement(adorner, adorned); } - private void UpdateClip(Control control, TransformedBounds bounds, bool isEnabled) + private static void AdornerIsClipEnabledChanged(AvaloniaPropertyChangedEventArgs e) + { + var info = ((Visual)e.Sender).GetValue(s_adornedElementInfoProperty); + info?.UpdateSubscription(); + info?.Layer?.InvalidateMeasure(); + } + + private void UpdateClip(Control control, Visual adorned, bool isEnabled) { if (!isEnabled) { control.Clip = null; - return; } - if (!(control.Clip is RectangleGeometry clip)) - { - clip = new RectangleGeometry(); - control.Clip = clip; - } - - var clipBounds = bounds.Bounds; - - if (bounds.Transform.HasInverse) - { - clipBounds = bounds.Clip.TransformToAABB(bounds.Transform.Invert()); - } - - clip.Rect = clipBounds; + control.Clip = AdornerHelper.CalculateAdornerClip(adorned); } private void ChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -313,24 +322,35 @@ namespace Avalonia.Controls.Primitives { if (info == null) { - info = new AdornedElementInfo(); + info = new AdornedElementInfo(adorner); adorner.SetValue(s_adornedElementInfoProperty, info); } - if (adorner.CompositionVisual != null) - info.Subscription = adorned.GetObservable(BoundsProperty).Subscribe(x => - { - info.Bounds = new TransformedBounds(new Rect(adorned.Bounds.Size), new Rect(adorned.Bounds.Size), Matrix.Identity); - InvalidateMeasure(); - }); + info.Layer = this; + info.AdornedElement = adorned; + info.UpdateSubscription(); } } - private class AdornedElementInfo + private class AdornedElementInfo(Visual adorner) { + public AdornerLayer? Layer { get; set; } public IDisposable? Subscription { get; set; } + public Visual? AdornedElement { get; set; } - public TransformedBounds? Bounds { get; set; } + public void UpdateSubscription() + { + Subscription?.Dispose(); + Subscription = null; + if (AdornedElement != null) + { + Subscription = AdornerHelper.SubscribeToAncestorPropertyChanges(AdornedElement, + GetIsClipEnabled(adorner), () => + { + Layer?.InvalidateMeasure(); + }); + } + } } } } diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 76fa73f4c3..5294df72d5 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -456,7 +456,7 @@ namespace Avalonia.Controls.Primitives if (InheritsTransform) { - TransformTrackingHelper.Track(placementTarget, PlacementTargetTransformChanged) + TransformTrackingHelper.Track(placementTarget, true, PlacementTargetTransformChanged) .DisposeWith(handlerCleanup); } else diff --git a/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs b/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs index 82dead1ed5..2f735ef691 100644 --- a/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs +++ b/src/Avalonia.Controls/Primitives/PopupPositioning/IPopupPositioner.cs @@ -570,16 +570,8 @@ namespace Avalonia.Controls.Primitives.PopupPositioning var target = positionRequest.Target; if (target == null) throw new InvalidOperationException("Placement mode is not Pointer and PlacementTarget is null"); - Matrix? matrix; - if (TryGetAdorner(target, out var adorned, out var adornerLayer)) - { - matrix = adorned!.TransformToVisual(topLevel) * target.TransformToVisual(adornerLayer!); - } - else - { - matrix = target.TransformToVisual(topLevel); - } - + Matrix? matrix = target.TransformToVisual(topLevel); + if (matrix == null) { if (target.GetVisualRoot() == null) @@ -591,25 +583,6 @@ namespace Avalonia.Controls.Primitives.PopupPositioning var anchorRect = positionRequest.AnchorRect ?? bounds; return anchorRect.Intersect(bounds).TransformToAABB(matrix.Value); } - - private static bool TryGetAdorner(Visual target, out Visual? adorned, out Visual? adornerLayer) - { - var element = target; - while (element != null) - { - if (AdornerLayer.GetAdornedElement(element) is { } adornedElement) - { - adorned = adornedElement; - adornerLayer = AdornerLayer.GetAdornerLayer(adorned); - return true; - } - element = element.VisualParent; - } - - adorned = null; - adornerLayer = null; - return false; - } } } From fe060db1063bb97a344148451b86404e80acdd07 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Mon, 16 Feb 2026 19:17:35 +0000 Subject: [PATCH 04/33] android - only handle back event from source toplevel (#20694) --- src/Avalonia.Controls/TopLevel.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 8a631fe075..313f9969d6 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -290,6 +290,9 @@ namespace Avalonia.Controls _backGestureSubscription = _inputManager?.PreProcess.Subscribe(e => { + if (e.Root != this) + return; + bool backRequested = false; if (e is RawKeyEventArgs rawKeyEventArgs && rawKeyEventArgs.Type == RawKeyEventType.KeyDown) From 8e8972aabf671e03dde88069adf62f334566e2b8 Mon Sep 17 00:00:00 2001 From: Adam Demasi Date: Thu, 19 Feb 2026 04:42:55 +1030 Subject: [PATCH 05/33] Make TextBox.PlaceholderText accessible (#20714) * Implement -accessibilityPlaceholderValue on macOS macOS uses a unique -accessibilityPlaceholderValue property for the placeholder of a text field; -accessibilityHelp has different meaning from HelpText on Windows. Map TextBox.PlaceholderText to be returned by -accessibilityPlaceholderValue. Ref: https://www.w3.org/TR/core-aam-1.2/#ariaPlaceholder * Use TextBox placeholder as a fallback for HelpText on Windows This matches the behavior introduced in 8593ef3 on macOS, as this is where UIA designates that placeholder text should go. Ref: https://www.w3.org/TR/core-aam-1.2/#ariaPlaceholder --- native/Avalonia.Native/src/OSX/automation.mm | 5 +++++ .../Pages/AutomationPage.axaml | 2 +- .../Automation/Peers/AutomationPeer.cs | 20 ++++++++++++++++++- .../Automation/Peers/ControlAutomationPeer.cs | 6 ++++++ .../Automation/Peers/InteropAutomationPeer.cs | 1 + .../Automation/Peers/TextBoxAutomationPeer.cs | 2 ++ src/Avalonia.Native/AvnAutomationPeer.cs | 1 + src/Avalonia.Native/avn.idl | 1 + 8 files changed, 36 insertions(+), 2 deletions(-) diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm index f8c288c489..b42dc22f7a 100644 --- a/native/Avalonia.Native/src/OSX/automation.mm +++ b/native/Avalonia.Native/src/OSX/automation.mm @@ -220,6 +220,11 @@ return GetNSStringAndRelease(_peer->GetHelpText()); } +- (NSString *)accessibilityPlaceholderValue +{ + return GetNSStringAndRelease(_peer->GetPlaceholderText()); +} + - (id)accessibilityValue { if (_peer->IsRangeValueProvider()) diff --git a/samples/IntegrationTestApp/Pages/AutomationPage.axaml b/samples/IntegrationTestApp/Pages/AutomationPage.axaml index c02bc1baa6..91317bcb11 100644 --- a/samples/IntegrationTestApp/Pages/AutomationPage.axaml +++ b/samples/IntegrationTestApp/Pages/AutomationPage.axaml @@ -10,7 +10,7 @@ TextBlockWithNameAndAutomationId Label for TextBox - + Foo diff --git a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs index b32b60118c..47754c9b50 100644 --- a/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/AutomationPeer.cs @@ -283,12 +283,29 @@ namespace Avalonia.Automation.Peers /// public string GetHelpText() => GetHelpTextCore() ?? string.Empty; + /// + /// Gets text that provides a placeholder for the element that is associated with this automation peer. + /// + /// + /// + /// + /// Windows + /// No mapping. + /// + /// + /// macOS + /// NSAccessibilityProtocol.accessibilityPlaceholderValue + /// + /// + /// + public string GetPlaceholderText() => GetPlaceholderTextCore() ?? string.Empty; + /// /// Gets the control type for the element that is associated with the UI Automation peer. /// /// /// Gets the type of the element. - /// + /// /// /// /// Windows @@ -595,6 +612,7 @@ namespace Avalonia.Automation.Peers protected abstract AutomationPeer? GetLabeledByCore(); protected abstract string? GetNameCore(); protected virtual string? GetHelpTextCore() => null; + protected virtual string? GetPlaceholderTextCore() => null; protected virtual AutomationLandmarkType? GetLandmarkTypeCore() => null; protected virtual int GetHeadingLevelCore() => 0; protected virtual string? GetItemTypeCore() => null; diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs index cdab4911f2..aaabc6f495 100644 --- a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs @@ -132,6 +132,12 @@ namespace Avalonia.Automation.Peers result = ToolTip.GetTip(Owner) as string; } + // Windows uses HelpText for placeholder text; macOS uses a separate property. + if (string.IsNullOrWhiteSpace(result)) + { + result = GetPlaceholderTextCore(); + } + return result; } protected override AutomationLandmarkType? GetLandmarkTypeCore() => AutomationProperties.GetLandmarkType(Owner); diff --git a/src/Avalonia.Controls/Automation/Peers/InteropAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/InteropAutomationPeer.cs index 5dfd507e1b..cdbb3286d8 100644 --- a/src/Avalonia.Controls/Automation/Peers/InteropAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/InteropAutomationPeer.cs @@ -29,6 +29,7 @@ internal class InteropAutomationPeer : AutomationPeer protected override AutomationPeer? GetLabeledByCore() => throw new NotImplementedException(); protected override string? GetNameCore() => throw new NotImplementedException(); protected override string? GetHelpTextCore() => throw new NotImplementedException(); + protected override string? GetPlaceholderTextCore() => throw new NotImplementedException(); protected override IReadOnlyList GetOrCreateChildrenCore() => throw new NotImplementedException(); protected override AutomationPeer? GetParentCore() => _parent; protected override bool HasKeyboardFocusCore() => throw new NotImplementedException(); diff --git a/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs index dacec48d36..4f41e088c6 100644 --- a/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/TextBoxAutomationPeer.cs @@ -21,6 +21,8 @@ namespace Avalonia.Automation.Peers return AutomationControlType.Edit; } + protected override string? GetPlaceholderTextCore() => Owner.PlaceholderText; + protected virtual void OwnerPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { if(e.Property == TextBox.TextProperty) diff --git a/src/Avalonia.Native/AvnAutomationPeer.cs b/src/Avalonia.Native/AvnAutomationPeer.cs index 86e0c5453d..cb418261f7 100644 --- a/src/Avalonia.Native/AvnAutomationPeer.cs +++ b/src/Avalonia.Native/AvnAutomationPeer.cs @@ -46,6 +46,7 @@ namespace Avalonia.Native public IAvnAutomationPeer? LabeledBy => Wrap(_inner.GetLabeledBy()); public IAvnString? Name => _inner.GetName().ToAvnString(); public IAvnString? HelpText => _inner.GetHelpText().ToAvnString(); + public IAvnString? PlaceholderText => _inner.GetPlaceholderText().ToAvnString(); public AvnLandmarkType LandmarkType => (AvnLandmarkType?)_inner.GetLandmarkType() ?? AvnLandmarkType.LandmarkNone; public int HeadingLevel => _inner.GetHeadingLevel(); public IAvnAutomationPeer? Parent => Wrap(_inner.GetParent()); diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index 7aabeceb72..ea8c1b4694 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -1328,6 +1328,7 @@ interface IAvnAutomationPeer : IUnknown void ValueProvider_SetValue([const] char* value); IAvnString* GetHelpText(); + IAvnString* GetPlaceholderText(); AvnLandmarkType GetLandmarkType(); int GetHeadingLevel(); From ef702642366d58560e22ccaed2efa46b3e6bf37a Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Thu, 19 Feb 2026 20:01:50 +0900 Subject: [PATCH 06/33] [macOS] Add NativeDock.Menu API for adding menu items to macOS dock icon (#20634) * Native DockMenu code * Add Native Interop * Update ControlCatalog sample * Add unit tests * Add Action to AvaloniaNativeMenuExporter * Add dynamic dock item demo * Move s_dockMenu reference to App * Use Appium tests * Revert INativeMenuExporterResetHandler * Properly set the button for the checkbox * Add dock test * I hate Appium * Rename NativeMenu.DockMenu to NativeDock.Menu * Make static * Remove Dock Click Test * Add white space back for cleaner diff * Reduce MenuExporter back to one * Revert UpdateIfNeeded to private * Revert QueueReset to private too... and fix some whitespace * Revert IAvnMenuItem/IAvnMenu back * That's what I get not comparing it to master * And update this too * Add documentation --- native/Avalonia.Native/src/OSX/app.mm | 12 ++ native/Avalonia.Native/src/OSX/common.h | 1 + native/Avalonia.Native/src/OSX/main.mm | 19 ++- samples/ControlCatalog/App.xaml | 9 ++ samples/ControlCatalog/App.xaml.cs | 34 +++++ samples/IntegrationTestApp/App.axaml | 7 + samples/IntegrationTestApp/App.axaml.cs | 25 ++++ .../Pages/DesktopPage.axaml | 6 + .../Pages/DesktopPage.axaml.cs | 10 ++ src/Avalonia.Controls/NativeDock.cs | 28 ++++ .../AvaloniaNativeMenuExporter.cs | 135 +++++++++++++++--- src/Avalonia.Native/AvaloniaNativePlatform.cs | 5 + .../AvaloniaNativePlatformExtensions.cs | 1 + src/Avalonia.Native/avn.idl | 1 + .../NativeMenuTests.cs | 41 +++++- 15 files changed, 304 insertions(+), 30 deletions(-) create mode 100644 src/Avalonia.Controls/NativeDock.cs diff --git a/native/Avalonia.Native/src/OSX/app.mm b/native/Avalonia.Native/src/OSX/app.mm index 5dc994fb6b..092bde9c07 100644 --- a/native/Avalonia.Native/src/OSX/app.mm +++ b/native/Avalonia.Native/src/OSX/app.mm @@ -1,11 +1,13 @@ #include "common.h" #include "AvnString.h" +#include "menu.h" @interface AvnAppDelegate : NSObject -(AvnAppDelegate* _Nonnull) initWithEvents: (IAvnApplicationEvents* _Nonnull) events; -(void) releaseEvents; @end NSApplicationActivationPolicy AvnDesiredActivationPolicy = NSApplicationActivationPolicyRegular; +static NSMenu* s_dockMenu = nil; @implementation AvnAppDelegate ComPtr _events; @@ -86,6 +88,11 @@ ComPtr _events; return _events->TryShutdown() ? NSTerminateNow : NSTerminateCancel; } +- (NSMenu *)applicationDockMenu:(NSApplication *)sender +{ + return s_dockMenu; +} + @end @interface AvnApplication : NSApplication @@ -180,3 +187,8 @@ extern IAvnApplicationCommands* CreateApplicationCommands() { return new AvnApplicationCommands(); } + +extern void SetDockMenu(NSMenu* menu) +{ + s_dockMenu = menu; +} diff --git a/native/Avalonia.Native/src/OSX/common.h b/native/Avalonia.Native/src/OSX/common.h index fae03984fd..a993784fc4 100644 --- a/native/Avalonia.Native/src/OSX/common.h +++ b/native/Avalonia.Native/src/OSX/common.h @@ -38,6 +38,7 @@ extern void SetAppMenu(IAvnMenu *menu); extern void SetServicesMenu (IAvnMenu* menu); extern IAvnMenu* GetAppMenu (); extern NSMenuItem* GetAppMenuItem (); +extern void SetDockMenu(NSMenu* menu); extern void InitializeAvnApp(IAvnApplicationEvents* events, bool disableAppDelegate); extern void ReleaseAvnAppEvents(); diff --git a/native/Avalonia.Native/src/OSX/main.mm b/native/Avalonia.Native/src/OSX/main.mm index 2a92eb3bcf..2f7e15c8ed 100644 --- a/native/Avalonia.Native/src/OSX/main.mm +++ b/native/Avalonia.Native/src/OSX/main.mm @@ -1,6 +1,7 @@ //This file will contain actual IID structures #define COM_GUIDS_MATERIALIZE #include "common.h" +#include "menu.h" static NSString* s_appTitle = @"Avalonia"; static int disableSetProcessName = 0; @@ -475,14 +476,24 @@ public: return *ppv != nullptr ? S_OK : E_FAIL; } - HRESULT CreateMemoryManagementHelper(IAvnNativeObjectsMemoryManagement **ppv) override { + HRESULT CreateMemoryManagementHelper(IAvnNativeObjectsMemoryManagement **ppv) override { START_COM_CALL; *ppv = ::CreateMemoryManagementHelper(); return S_OK; } - - - + + virtual HRESULT SetDockMenu(IAvnMenu* dockMenu) override + { + START_COM_CALL; + + @autoreleasepool + { + auto nativeMenu = dynamic_cast(dockMenu); + ::SetDockMenu(nativeMenu != nullptr ? nativeMenu->GetNative() : nil); + return S_OK; + } + } + }; extern "C" IAvaloniaNativeFactory* CreateAvaloniaNative() diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml index 022118d3ab..179f64233e 100644 --- a/samples/ControlCatalog/App.xaml +++ b/samples/ControlCatalog/App.xaml @@ -59,6 +59,15 @@ + + + + + + + + + diff --git a/samples/ControlCatalog/App.xaml.cs b/samples/ControlCatalog/App.xaml.cs index b08df1223d..f14fbb1fa3 100644 --- a/samples/ControlCatalog/App.xaml.cs +++ b/samples/ControlCatalog/App.xaml.cs @@ -64,6 +64,40 @@ namespace ControlCatalog base.OnFrameworkInitializationCompleted(); } + public void OnDockNewWindowClicked(object? sender, EventArgs e) + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime) + { + var window = new MainWindow(); + window.Show(); + } + } + + public void OnDockShowMainWindowClicked(object? sender, EventArgs e) + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + desktopLifetime.MainWindow?.Activate(); + } + } + + private int _dockMenuItemCount; + + public void OnDockAddItemClicked(object? sender, EventArgs e) + { + var dockMenu = NativeDock.GetMenu(this); + if (dockMenu is not null) + { + _dockMenuItemCount++; + var item = new NativeMenuItem($"New item {_dockMenuItemCount}"); + item.Click += (_, _) => + { + dockMenu.Items.Remove(item); + }; + dockMenu.Items.Insert(0, item); + } + } + private CatalogTheme _prevTheme; public static CatalogTheme CurrentTheme => ((App)Current!)._prevTheme; public static void SetCatalogThemes(CatalogTheme theme) diff --git a/samples/IntegrationTestApp/App.axaml b/samples/IntegrationTestApp/App.axaml index 60a2c56542..e8c91fe580 100644 --- a/samples/IntegrationTestApp/App.axaml +++ b/samples/IntegrationTestApp/App.axaml @@ -7,6 +7,13 @@ + + + + + (name).IsChecked = true; }); + DockMenuCommand = MiniCommand.Create(name => + { + // This is for the "Show Main Window" dock menu item in the test. + // It doesn't actually show the main window, but sets the checkbox to true in the page. + var checkbox = _mainWindow!.GetLogicalDescendants().OfType().FirstOrDefault(x => x.Name == name); + if (checkbox != null) checkbox.IsChecked = true; + }); DataContext = this; } @@ -37,5 +46,21 @@ namespace IntegrationTestApp } public ICommand TrayIconCommand { get; } + public ICommand DockMenuCommand { get; } + + public void AddDockMenuItem(string header) + { + var dockMenu = NativeDock.GetMenu(this); + if (dockMenu is not null) + { + dockMenu.Items.Insert(0, new NativeMenuItem(header)); + } + } + + public int GetDockMenuItemCount() + { + var dockMenu = NativeDock.GetMenu(this); + return dockMenu?.Items.Count ?? 0; + } } } diff --git a/samples/IntegrationTestApp/Pages/DesktopPage.axaml b/samples/IntegrationTestApp/Pages/DesktopPage.axaml index a5495bd347..d7044df525 100644 --- a/samples/IntegrationTestApp/Pages/DesktopPage.axaml +++ b/samples/IntegrationTestApp/Pages/DesktopPage.axaml @@ -10,5 +10,11 @@