From b25f2d98c2ccc02170038023f71a53cd368e9d76 Mon Sep 17 00:00:00 2001 From: Jumar Macato <16554748+jmacato@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:18:38 +0800 Subject: [PATCH] AT-SPI: Expose list item containers as accessible children (#21605) * AT-SPI: add to the selection in Selection.SelectChild instead of replacing it * AT-SPI: expose list item containers as accessible children Index selectable items directly under the selection container so the Selection interface's SelectChild/DeselectChild address them by index. * Expose ListBox selection to accessibility via a selection peer ListBox used ItemsControlAutomationPeer, which is not an ISelectionProvider, so it never exposed the Selection interface (AT-SPI) for multi-select. * Address review: expose item containers from the base ItemsControl peer Move the GetChildrenCore container override down to ItemsControlAutomationPeer so every ItemsControl returns its containers, and return null directly when there are no items instead of calling the base algorithm. * Use file-scoped namespace in ListBoxAutomationPeer * Fix cross-platform regression: move child flattening from the peer to the AT-SPI selection handler * Trigger rebuild * AT-SPI: flatten selection container item children in the node layer, not the shared peer Present a SelectingItemsControl's realized item containers as its direct AT-SPI children inside AtSpiNode so SelectChild-by-index and a selectable item's parent lookup resolve to the Selection container. Doing this in the FreeDesktop AT-SPI layer keeps the macOS AX and Windows UIA peer trees intact, avoiding the item-frame and tree-collapse regressions the shared ItemsControlAutomationPeer override caused. --- .../Peers/ItemsControlAutomationPeer.cs | 3 +- .../Automation/Peers/ListBoxAutomationPeer.cs | 11 ++++ src/Avalonia.Controls/ListBox.cs | 6 ++ src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.cs | 28 +++++++- .../Handlers/AtSpiSelectionHandler.cs | 65 ++++++++++--------- 5 files changed, 78 insertions(+), 35 deletions(-) create mode 100644 src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs diff --git a/src/Avalonia.Controls/Automation/Peers/ItemsControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ItemsControlAutomationPeer.cs index 64727c43c5..278e6cdfce 100644 --- a/src/Avalonia.Controls/Automation/Peers/ItemsControlAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ItemsControlAutomationPeer.cs @@ -1,4 +1,5 @@ -using Avalonia.Automation.Provider; +using System.Collections.Generic; +using Avalonia.Automation.Provider; using Avalonia.Controls; namespace Avalonia.Automation.Peers diff --git a/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs new file mode 100644 index 0000000000..d8aae6f82c --- /dev/null +++ b/src/Avalonia.Controls/Automation/Peers/ListBoxAutomationPeer.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Avalonia.Automation.Peers; + +public class ListBoxAutomationPeer : SelectingItemsControlAutomationPeer +{ + public ListBoxAutomationPeer(ListBox owner) + : base(owner) + { + } +} diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 3ef75f4990..9307bf9b44 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Drawing; using System.Linq; +using Avalonia.Automation.Peers; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; using Avalonia.Controls.Selection; @@ -126,6 +127,11 @@ namespace Avalonia.Controls return NeedsContainer(item, out recycleKey); } + protected override AutomationPeer OnCreateAutomationPeer() + { + return new ListBoxAutomationPeer(this); + } + protected override void OnKeyDown(KeyEventArgs e) { var hotkeys = this.GetPlatformSettings()?.HotkeyConfiguration; diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.cs index dc099464a6..09fec39c0f 100644 --- a/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.cs +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.cs @@ -164,6 +164,30 @@ namespace Avalonia.FreeDesktop.AtSpi internal bool RemoveAttachedChild(AtSpiNode child) => _attachedChildren.Remove(child); + // Present a selection container's realized item containers as its direct + // AT-SPI children so SelectChild-by-index and parent lookups work. + private IReadOnlyList GetChildPeers() + { + if (Peer.GetProvider() is null) + return Peer.GetChildren(); + + var items = new List(); + CollectSelectionItemPeers(Peer.GetChildren(), items); + return items.Count > 0 ? items : Peer.GetChildren(); + } + + private static void CollectSelectionItemPeers( + IReadOnlyList peers, List result) + { + foreach (var peer in peers) + { + if (peer.GetProvider() is not null) + result.Add(peer); + else + CollectSelectionItemPeers(peer.GetChildren(), result); + } + } + internal IReadOnlyList EnsureChildren() { if (!IsAttached) @@ -172,7 +196,7 @@ namespace Avalonia.FreeDesktop.AtSpi if (!_childrenDirty) return _attachedChildren; - var childPeers = Peer.GetChildren(); + var childPeers = GetChildPeers(); var nextChildren = new List(childPeers.Count); var nextChildrenSet = new HashSet(); foreach (var childPeer in childPeers) @@ -275,7 +299,7 @@ namespace Avalonia.FreeDesktop.AtSpi _childrenDirty = true; - var childPeers = Peer.GetChildren(); + var childPeers = GetChildPeers(); if (_attachedChildren.Count > 0) { var currentPeers = new HashSet(childPeers); diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiSelectionHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiSelectionHandler.cs index 95bd6e032d..85d76374c5 100644 --- a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiSelectionHandler.cs +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiSelectionHandler.cs @@ -1,6 +1,6 @@ -using System; using System.Collections.Generic; using System.Threading.Tasks; +using Avalonia.Automation.Peers; using Avalonia.Automation.Provider; using Avalonia.DBus; using Avalonia.FreeDesktop.AtSpi.DBusXml; @@ -8,9 +8,6 @@ using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; namespace Avalonia.FreeDesktop.AtSpi.Handlers { - /// - /// Implements the AT-SPI Selection interface for list-like containers. - /// internal sealed class AtSpiSelectionHandler(AtSpiServer server, AtSpiNode node) : IOrgA11yAtspiSelection { public uint Version => SelectionVersion; @@ -38,7 +35,7 @@ namespace Avalonia.FreeDesktop.AtSpi.Handlers var selectedPeer = selection[selectedChildIndex]; var childNode = server.TryGetAttachedNode(selectedPeer); - if (childNode is null || !ReferenceEquals(childNode.Parent, node)) + if (childNode is null) return ValueTask.FromResult(server.GetNullReference()); return ValueTask.FromResult(server.GetReference(childNode)); @@ -46,15 +43,11 @@ namespace Avalonia.FreeDesktop.AtSpi.Handlers public ValueTask SelectChildAsync(int childIndex) { - var children = node.Peer.GetChildren(); - if (childIndex < 0 || childIndex >= children.Count) + var items = CollectSelectableItems(node.Peer); + if (childIndex < 0 || childIndex >= items.Count) return ValueTask.FromResult(false); - var childPeer = children[childIndex]; - if (childPeer.GetProvider() is not { } selectionItem) - return ValueTask.FromResult(false); - - selectionItem.AddToSelection(); + items[childIndex].AddToSelection(); return ValueTask.FromResult(true); } @@ -78,15 +71,11 @@ namespace Avalonia.FreeDesktop.AtSpi.Handlers public ValueTask IsChildSelectedAsync(int childIndex) { - var children = node.Peer.GetChildren(); - if (childIndex < 0 || childIndex >= children.Count) + var items = CollectSelectableItems(node.Peer); + if (childIndex < 0 || childIndex >= items.Count) return ValueTask.FromResult(false); - var childPeer = children[childIndex]; - if (childPeer.GetProvider() is not { } selectionItem) - return ValueTask.FromResult(false); - - return ValueTask.FromResult(selectionItem.IsSelected); + return ValueTask.FromResult(items[childIndex].IsSelected); } public ValueTask SelectAllAsync() @@ -95,12 +84,8 @@ namespace Avalonia.FreeDesktop.AtSpi.Handlers if (provider is null || !provider.CanSelectMultiple) return ValueTask.FromResult(false); - var children = node.Peer.GetChildren(); - foreach (var child in children) - { - if (child.GetProvider() is { } selectionItem) - selectionItem.AddToSelection(); - } + foreach (var item in CollectSelectableItems(node.Peer)) + item.AddToSelection(); return ValueTask.FromResult(true); } @@ -123,16 +108,32 @@ namespace Avalonia.FreeDesktop.AtSpi.Handlers public ValueTask DeselectChildAsync(int childIndex) { - var children = node.Peer.GetChildren(); - if (childIndex < 0 || childIndex >= children.Count) + var items = CollectSelectableItems(node.Peer); + if (childIndex < 0 || childIndex >= items.Count) return ValueTask.FromResult(false); - var childPeer = children[childIndex]; - if (childPeer.GetProvider() is not { } selectionItem) - return ValueTask.FromResult(false); - - selectionItem.RemoveFromSelection(); + items[childIndex].RemoveFromSelection(); return ValueTask.FromResult(true); } + + private static List CollectSelectableItems(AutomationPeer peer) + { + var result = new List(); + CollectSelectableItemsCore(peer.GetChildren(), result); + return result; + } + + private static void CollectSelectableItemsCore( + IReadOnlyList children, + List result) + { + foreach (var child in children) + { + if (child.GetProvider() is { } item) + result.Add(item); + else + CollectSelectableItemsCore(child.GetChildren(), result); + } + } } }