diff --git a/Avalonia.sln b/Avalonia.sln index 06c3e051ac..40c9ee3f11 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -159,6 +159,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Dialogs", "src\Ava EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.FreeDesktop", "src\Avalonia.FreeDesktop\Avalonia.FreeDesktop.csproj", "{4D36CEC8-53F2-40A5-9A37-79AAE356E2DA}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.FreeDesktop.AtSpi", "src\Avalonia.FreeDesktop.AtSpi\Avalonia.FreeDesktop.AtSpi.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Themes.Fluent", "src\Avalonia.Themes.Fluent\Avalonia.Themes.Fluent.csproj", "{C42D2FC1-A531-4ED4-84B9-89AEC7C962FC}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Headless", "src\Headless\Avalonia.Headless\Avalonia.Headless.csproj", "{8C89950F-F5D9-47FC-8066-CBC1EC3DF8FC}" diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiCacheHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiCacheHandler.cs new file mode 100644 index 0000000000..42a551298e --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiCacheHandler.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + internal sealed class AtSpiCacheHandler : IOrgA11yAtspiCache + { + private readonly AtSpiServer _server; + + public AtSpiCacheHandler(AtSpiServer server) + { + _server = server; + } + + public uint Version => CacheVersion; + + public ValueTask> GetItemsAsync() + { + List snapshot; + lock (_server.TreeGate) + { + snapshot = _server.GetAllNodes() + .OrderBy(static node => node.Path, System.StringComparer.Ordinal) + .ToList(); + } + + var items = new List(snapshot.Count); + items.AddRange(snapshot.Select(n => _server.BuildCacheItem(n))); + return ValueTask.FromResult(items); + } + + public void EmitAddAccessibleSignal(AtSpiAccessibleCacheItem item) + { + EmitSignal("AddAccessible", item); + } + + public void EmitRemoveAccessibleSignal(AtSpiObjectReference node) + { + EmitSignal("RemoveAccessible", node); + } + + private void EmitSignal(string member, params object[] body) + { + var connection = _server.A11yConnection; + if (connection is null) + return; + + var message = DBusMessage.CreateSignal( + (DBusObjectPath)CachePath, + IfaceCache, + member, + body); + + _ = connection.SendMessageAsync(message); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiConstants.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiConstants.cs new file mode 100644 index 0000000000..7930ff8b78 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiConstants.cs @@ -0,0 +1,246 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace Avalonia.FreeDesktop.AtSpi +{ + /// + /// AT-SPI2 role IDs from atspi-constants.h. + /// + internal enum AtSpiRole + { + Invalid = 0, + Accelerator = 1, + Alert = 2, + Animation = 3, + Arrow = 4, + Calendar = 5, + Canvas = 6, + CheckBox = 7, + CheckMenuItem = 8, + ColorChooser = 9, + ColumnHeader = 10, + ComboBox = 11, + DateEditor = 12, + DesktopIcon = 13, + DesktopFrame = 14, + Dial = 15, + Dialog = 16, + DirectoryPane = 17, + DrawingArea = 18, + FileChooser = 19, + Filler = 20, + FocusTraversable = 21, + Frame = 23, + GlassPane = 24, + HtmlContainer = 25, + Icon = 26, + Image = 27, + InternalFrame = 28, + Label = 29, + LayeredPane = 30, + List = 31, + ListItem = 32, + Menu = 33, + MenuBar = 34, + MenuItem = 35, + OptionPane = 36, + PageTab = 37, + PageTabList = 38, + Panel = 39, + PasswordText = 40, + PopupMenu = 41, + ProgressBar = 42, + PushButton = 43, + RadioButton = 44, + RadioMenuItem = 45, + RootPane = 46, + RowHeader = 47, + ScrollBar = 48, + ScrollPane = 49, + Separator = 50, + Slider = 51, + SpinButton = 52, + SplitPane = 53, + StatusBar = 54, + Table = 55, + TableCell = 56, + TableColumnHeader = 57, + TableRowHeader = 58, + TearoffMenuItem = 59, + Terminal = 60, + Text = 61, + ToggleButton = 62, + ToolBar = 63, + ToolTip = 64, + Tree = 65, + TreeTable = 66, + Unknown = 67, + Viewport = 68, + Window = 69, + Extended = 70, + Header = 71, + Footer = 72, + Paragraph = 73, + Ruler = 74, + Application = 75, + AutoComplete = 76, + EditBar = 77, + Embedded = 78, + Entry = 79, + Heading = 81, + Page = 82, + Document = 83, + Section = 84, + RedundantObject = 85, + Form = 86, + Link = 87, + InputMethodWindow = 88, + TableRow = 89, + TreeItem = 90, + DocumentSpreadsheet = 91, + DocumentPresentation = 92, + DocumentText = 93, + DocumentWeb = 94, + DocumentEmail = 95, + Comment = 96, + ListBox = 97, + Grouping = 98, + ImageMap = 99, + Notification = 100, + InfoBar = 101, + LevelBar = 102, + TitleBar = 103, + BlockQuote = 104, + Audio = 105, + Video = 106, + Definition = 107, + Article = 108, + Landmark = 109, + Log = 110, + Marquee = 111, + Math = 112, + Rating = 113, + Timer = 114, + Static = 116, + MathFraction = 117, + MathRoot = 118, + Subscript = 119, + Superscript = 120, + } + + /// + /// AT-SPI2 state IDs from atspi-constants.h. + /// + internal enum AtSpiState : uint + { + Invalid = 0, + Active = 1, + Armed = 2, + Busy = 3, + Checked = 4, + Collapsed = 5, + Defunct = 6, + Editable = 7, + Enabled = 8, + Expandable = 9, + Expanded = 10, + Focusable = 11, + Focused = 12, + HasToolTip = 13, + Horizontal = 14, + Iconified = 15, + Modal = 16, + MultiLine = 17, + MultiSelectable = 18, + Opaque = 19, + Pressed = 20, + Resizable = 21, + Selectable = 22, + Selected = 23, + Sensitive = 24, + Showing = 25, + SingleLine = 26, + Stale = 27, + Transient = 28, + Vertical = 29, + Visible = 30, + ManagesDescendants = 31, + Indeterminate = 32, + Required = 33, + Truncated = 34, + Animated = 35, + InvalidEntry = 36, + SupportsAutoCompletion = 37, + SelectableText = 38, + IsDefault = 39, + Visited = 40, + Checkable = 41, + HasPopup = 42, + ReadOnly = 43, + } + + internal static class AtSpiConstants + { + // D-Bus paths + internal const string RootPath = "/org/a11y/atspi/accessible/root"; + internal const string CachePath = "/org/a11y/atspi/cache"; + internal const string NullPath = "/org/a11y/atspi/null"; + internal const string AppPathPrefix = "/org/avalonia/a11y"; + internal const string RegistryPath = "/org/a11y/atspi/registry"; + + // Interface names + internal const string IfaceAccessible = "org.a11y.atspi.Accessible"; + internal const string IfaceApplication = "org.a11y.atspi.Application"; + internal const string IfaceComponent = "org.a11y.atspi.Component"; + internal const string IfaceAction = "org.a11y.atspi.Action"; + internal const string IfaceValue = "org.a11y.atspi.Value"; + internal const string IfaceEventObject = "org.a11y.atspi.Event.Object"; + internal const string IfaceCache = "org.a11y.atspi.Cache"; + + // Bus names + internal const string BusNameRegistry = "org.a11y.atspi.Registry"; + internal const string BusNameA11y = "org.a11y.Bus"; + internal const string PathA11y = "/org/a11y/bus"; + + // Interface versions + internal const uint AccessibleVersion = 1; + internal const uint ApplicationVersion = 1; + internal const uint ComponentVersion = 1; + internal const uint ActionVersion = 1; + internal const uint ValueVersion = 1; + internal const uint EventObjectVersion = 1; + internal const uint CacheVersion = 1; + + internal static List BuildStateSet(IReadOnlyCollection states) + { + if (states == null || states.Count == 0) + return new List { 0u, 0u }; + + uint low = 0; + uint high = 0; + foreach (var state in states) + { + var bit = (uint)state; + if (bit < 32) + low |= 1u << (int)bit; + else if (bit < 64) + high |= 1u << (int)(bit - 32); + } + + return new List { low, high }; + } + + internal static string ResolveLocale() + { + var culture = CultureInfo.CurrentUICulture.Name; + if (string.IsNullOrWhiteSpace(culture)) + culture = "en_US"; + return culture.Replace('-', '_'); + } + + internal static string ResolveToolkitVersion() + { + return typeof(AtSpiConstants).Assembly.GetName().Version?.ToString() ?? "0"; + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.RoleMapping.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.RoleMapping.cs new file mode 100644 index 0000000000..75ebaaf6c1 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.RoleMapping.cs @@ -0,0 +1,103 @@ +using Avalonia.Automation.Peers; + +namespace Avalonia.FreeDesktop.AtSpi +{ + internal partial class AtSpiNode + { + public static AtSpiRole ToAtSpiRole(AutomationControlType controlType) + { + return controlType switch + { + AutomationControlType.None => AtSpiRole.Panel, + AutomationControlType.Button => AtSpiRole.PushButton, + AutomationControlType.Calendar => AtSpiRole.Calendar, + AutomationControlType.CheckBox => AtSpiRole.CheckBox, + AutomationControlType.ComboBox => AtSpiRole.ComboBox, + AutomationControlType.ComboBoxItem => AtSpiRole.ListItem, + AutomationControlType.Edit => AtSpiRole.Entry, + AutomationControlType.Hyperlink => AtSpiRole.Link, + AutomationControlType.Image => AtSpiRole.Image, + AutomationControlType.ListItem => AtSpiRole.ListItem, + AutomationControlType.List => AtSpiRole.List, + AutomationControlType.Menu => AtSpiRole.Menu, + AutomationControlType.MenuBar => AtSpiRole.MenuBar, + AutomationControlType.MenuItem => AtSpiRole.MenuItem, + AutomationControlType.ProgressBar => AtSpiRole.ProgressBar, + AutomationControlType.RadioButton => AtSpiRole.RadioButton, + AutomationControlType.ScrollBar => AtSpiRole.ScrollBar, + AutomationControlType.Slider => AtSpiRole.Slider, + AutomationControlType.Spinner => AtSpiRole.SpinButton, + AutomationControlType.StatusBar => AtSpiRole.StatusBar, + AutomationControlType.Tab => AtSpiRole.PageTabList, + AutomationControlType.TabItem => AtSpiRole.PageTab, + AutomationControlType.Text => AtSpiRole.Label, + AutomationControlType.ToolBar => AtSpiRole.ToolBar, + AutomationControlType.ToolTip => AtSpiRole.ToolTip, + AutomationControlType.Tree => AtSpiRole.Tree, + AutomationControlType.TreeItem => AtSpiRole.TreeItem, + AutomationControlType.Custom => AtSpiRole.Unknown, + AutomationControlType.Group => AtSpiRole.Panel, + AutomationControlType.Thumb => AtSpiRole.PushButton, + AutomationControlType.DataGrid => AtSpiRole.TreeTable, + AutomationControlType.DataItem => AtSpiRole.TableCell, + AutomationControlType.Document => AtSpiRole.Document, + AutomationControlType.SplitButton => AtSpiRole.PushButton, + AutomationControlType.Window => AtSpiRole.Frame, + AutomationControlType.Pane => AtSpiRole.Panel, + AutomationControlType.Header => AtSpiRole.Header, + AutomationControlType.HeaderItem => AtSpiRole.ColumnHeader, + AutomationControlType.Table => AtSpiRole.Table, + AutomationControlType.TitleBar => AtSpiRole.TitleBar, + AutomationControlType.Separator => AtSpiRole.Separator, + AutomationControlType.Expander => AtSpiRole.Panel, + _ => AtSpiRole.Unknown, + }; + } + + public static string ToAtSpiRoleName(AtSpiRole role) + { + return role switch + { + AtSpiRole.Application => "application", + AtSpiRole.Frame => "frame", + AtSpiRole.PushButton => "push button", + AtSpiRole.CheckBox => "check box", + AtSpiRole.ComboBox => "combo box", + AtSpiRole.Entry => "entry", + AtSpiRole.Label => "label", + AtSpiRole.Image => "image", + AtSpiRole.List => "list", + AtSpiRole.ListItem => "list item", + AtSpiRole.Menu => "menu", + AtSpiRole.MenuBar => "menu bar", + AtSpiRole.MenuItem => "menu item", + AtSpiRole.ProgressBar => "progress bar", + AtSpiRole.RadioButton => "radio button", + AtSpiRole.ScrollBar => "scroll bar", + AtSpiRole.Slider => "slider", + AtSpiRole.SpinButton => "spin button", + AtSpiRole.StatusBar => "status bar", + AtSpiRole.PageTab => "page tab", + AtSpiRole.PageTabList => "page tab list", + AtSpiRole.ToolBar => "tool bar", + AtSpiRole.ToolTip => "tool tip", + AtSpiRole.Tree => "tree", + AtSpiRole.TreeItem => "tree item", + AtSpiRole.Panel => "panel", + AtSpiRole.Separator => "separator", + AtSpiRole.Table => "table", + AtSpiRole.TableCell => "table cell", + AtSpiRole.TreeTable => "tree table", + AtSpiRole.ColumnHeader => "column header", + AtSpiRole.Header => "header", + AtSpiRole.TitleBar => "title bar", + AtSpiRole.Document => "document frame", + AtSpiRole.Link => "link", + AtSpiRole.Calendar => "calendar", + AtSpiRole.Window => "window", + AtSpiRole.Unknown => "unknown", + _ => "unknown", + }; + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.StateMapping.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.StateMapping.cs new file mode 100644 index 0000000000..6b19693ed0 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.StateMapping.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + internal partial class AtSpiNode + { + public List ComputeStates() + { + return InvokeSync(() => ComputeStatesCore()); + } + + private List ComputeStatesCore() + { + var states = new HashSet(); + + if (Peer.IsEnabled()) + { + states.Add(AtSpiState.Enabled); + states.Add(AtSpiState.Sensitive); + } + + if (!Peer.IsOffscreen()) + { + states.Add(AtSpiState.Visible); + states.Add(AtSpiState.Showing); + } + + if (Peer.IsKeyboardFocusable()) + states.Add(AtSpiState.Focusable); + + if (Peer.HasKeyboardFocus()) + states.Add(AtSpiState.Focused); + + // Toggle state + if (Peer.GetProvider() is { } toggle) + { + states.Add(AtSpiState.Checkable); + switch (toggle.ToggleState) + { + case ToggleState.On: + states.Add(AtSpiState.Checked); + break; + case ToggleState.Indeterminate: + states.Add(AtSpiState.Indeterminate); + break; + } + } + + // Expand/collapse state + if (Peer.GetProvider() is { } expandCollapse) + { + states.Add(AtSpiState.Expandable); + switch (expandCollapse.ExpandCollapseState) + { + case ExpandCollapseState.Expanded: + states.Add(AtSpiState.Expanded); + break; + case ExpandCollapseState.Collapsed: + states.Add(AtSpiState.Collapsed); + break; + } + } + + // Range value read-only + if (Peer.GetProvider() is { } rangeValue) + { + if (rangeValue.IsReadOnly) + states.Add(AtSpiState.ReadOnly); + } + + // Window-level active state + var controlType = Peer.GetAutomationControlType(); + if (controlType == AutomationControlType.Window) + states.Add(AtSpiState.Active); + + return BuildStateSet(states); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.cs new file mode 100644 index 0000000000..4111f5cb83 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.Threading; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + internal partial class AtSpiNode + { + private static readonly ConditionalWeakTable s_nodes = new(); + private static int s_nextId; + + private readonly string _path; + + public AtSpiNode(AutomationPeer peer, AtSpiServer server) + { + Peer = peer; + Server = server; + _path = $"{AppPathPrefix}/{s_nextId++}"; + s_nodes.Add(peer, this); + peer.ChildrenChanged += OnPeerChildrenChanged; + peer.PropertyChanged += OnPeerPropertyChanged; + } + + protected AtSpiNode(AutomationPeer peer, AtSpiServer server, string path) + { + Peer = peer; + Server = server; + _path = path; + s_nodes.Add(peer, this); + peer.ChildrenChanged += OnPeerChildrenChanged; + peer.PropertyChanged += OnPeerPropertyChanged; + } + + public AutomationPeer Peer { get; } + public AtSpiServer Server { get; } + public string Path => _path; + public AtSpiNodeHandlers? Handlers { get; set; } + + public HashSet GetSupportedInterfaces() + { + var interfaces = new HashSet(StringComparer.Ordinal) { IfaceAccessible }; + + if (Peer.GetProvider() is not null) + interfaces.Add(IfaceApplication); + + // All visual elements support Component + interfaces.Add(IfaceComponent); + + if (Peer.GetProvider() is not null || + Peer.GetProvider() is not null || + Peer.GetProvider() is not null) + { + interfaces.Add(IfaceAction); + } + + if (Peer.GetProvider() is not null) + interfaces.Add(IfaceValue); + + return interfaces; + } + +#if NET6_0_OR_GREATER + [return: NotNullIfNotNull(nameof(peer))] +#endif + public static AtSpiNode? GetOrCreate(AutomationPeer? peer, AtSpiServer server) + { + if (peer is null) + return null; + + return s_nodes.GetValue(peer, p => + { + if (p.GetProvider() is not null) + return new RootAtSpiNode(p, server); + return new AtSpiNode(p, server); + }); + } + + public static AtSpiNode? TryGet(AutomationPeer? peer) + { + if (peer is null) + return null; + s_nodes.TryGetValue(peer, out var node); + return node; + } + + public static void Release(AutomationPeer peer) => s_nodes.Remove(peer); + + internal void InvokeSync(Action action) + { + if (Dispatcher.UIThread.CheckAccess()) + action(); + else + Dispatcher.UIThread.InvokeAsync(action).Wait(); + } + + internal T InvokeSync(Func func) + { + if (Dispatcher.UIThread.CheckAccess()) + return func(); + else + return Dispatcher.UIThread.InvokeAsync(func).Result; + } + + internal TResult InvokeSync(Func func) + { + if (Peer.GetProvider() is TInterface provider) + return InvokeSync(() => func(provider)); + throw new NotSupportedException($"Peer does not implement {typeof(TInterface).Name}."); + } + + internal void InvokeSync(Action action) + { + if (Peer.GetProvider() is TInterface provider) + InvokeSync(() => action(provider)); + else + throw new NotSupportedException($"Peer does not implement {typeof(TInterface).Name}."); + } + + private void OnPeerChildrenChanged(object? sender, EventArgs e) + { + Server.EmitChildrenChanged(this); + } + + private void OnPeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) + { + Server.EmitPropertyChange(this, e); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiNodeHandlers.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNodeHandlers.cs new file mode 100644 index 0000000000..6655dd8a1f --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNodeHandlers.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.Handlers; + +namespace Avalonia.FreeDesktop.AtSpi +{ + internal sealed class AtSpiNodeHandlers + { + public AtSpiNodeHandlers(AtSpiNode node) + { + Node = node; + } + + public AtSpiNode Node { get; } + public AtSpiAccessibleHandler? AccessibleHandler { get; set; } + public AtSpiApplicationHandler? ApplicationHandler { get; set; } + public AtSpiComponentHandler? ComponentHandler { get; set; } + public AtSpiActionHandler? ActionHandler { get; set; } + public AtSpiValueHandler? ValueHandler { get; set; } + public AtSpiEventObjectHandler? EventObjectHandler { get; set; } + + public IDisposable Register( + IDBusConnection connection, + SynchronizationContext? synchronizationContext = null) + { + ArgumentNullException.ThrowIfNull(connection); + + var targets = new List(); + if (AccessibleHandler != null) + targets.Add(AccessibleHandler); + if (ApplicationHandler != null) + targets.Add(ApplicationHandler); + if (ComponentHandler != null) + targets.Add(ComponentHandler); + if (ActionHandler != null) + targets.Add(ActionHandler); + if (ValueHandler != null) + targets.Add(ValueHandler); + if (EventObjectHandler != null) + targets.Add(EventObjectHandler); + + if (targets.Count == 0) + return EmptyRegistration.Instance; + + return connection.RegisterObjects((DBusObjectPath)Node.Path, targets, synchronizationContext); + } + + private sealed class EmptyRegistration : IDisposable + { + public static EmptyRegistration Instance { get; } = new(); + public void Dispose() { } + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiServer.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiServer.cs new file mode 100644 index 0000000000..1d7e372dbf --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiServer.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using Avalonia.FreeDesktop.AtSpi.Handlers; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + internal sealed class AtSpiServer : IAsyncDisposable + { + private readonly object _treeGate = new(); + private readonly Dictionary _nodesByPath = new(StringComparer.Ordinal); + private readonly Dictionary _pathRegistrations = new(StringComparer.Ordinal); + + private DBusConnection? _a11yConnection; + private string _uniqueName = string.Empty; + private AtSpiCacheHandler? _cacheHandler; + private RootAtSpiNode? _rootNode; + + internal DBusConnection? A11yConnection => _a11yConnection; + internal object TreeGate => _treeGate; + + public async Task StartAsync(AutomationPeer rootPeer) + { + // 1. Get a11y bus address + var address = await GetAccessibilityBusAddressAsync(); + if (string.IsNullOrWhiteSpace(address)) + throw new InvalidOperationException("Failed to resolve the accessibility bus address."); + + // 2. Connect to a11y bus + _a11yConnection = await DBusConnection.ConnectAsync(address); + _uniqueName = await _a11yConnection.GetUniqueNameAsync() ?? string.Empty; + + // 3. Create root node + _rootNode = new RootAtSpiNode(rootPeer, this); + RegisterNode(_rootNode); + + // 4. Walk peer tree + WalkAndCreateNodes(rootPeer); + + // 5. Build handlers for all nodes + BuildHandlers(); + + // 6. Register D-Bus object paths + RefreshPathRegistrations(); + + // 7. Embed with registry + await EmbedApplicationAsync(); + + // 8. Emit initial cache snapshot + EmitInitialCacheSnapshot(); + } + + public async ValueTask DisposeAsync() + { + foreach (var registration in _pathRegistrations.Values) + registration.Registration.Dispose(); + _pathRegistrations.Clear(); + + if (_a11yConnection is not null) + { + await _a11yConnection.DisposeAsync(); + _a11yConnection = null; + } + + _uniqueName = string.Empty; + _cacheHandler = null; + _rootNode = null; + _nodesByPath.Clear(); + } + + internal void RegisterNode(AtSpiNode node) + { + lock (_treeGate) + { + _nodesByPath[node.Path] = node; + } + } + + internal void UnregisterNode(AtSpiNode node) + { + lock (_treeGate) + { + _nodesByPath.Remove(node.Path); + } + } + + internal IReadOnlyCollection GetAllNodes() + { + lock (_treeGate) + { + return _nodesByPath.Values.ToArray(); + } + } + + internal AtSpiObjectReference GetReference(AtSpiNode? node) + { + if (node is null) + return GetNullReference(); + return new AtSpiObjectReference(_uniqueName, new DBusObjectPath(node.Path)); + } + + internal AtSpiObjectReference GetNullReference() + { + return new AtSpiObjectReference(string.Empty, new DBusObjectPath(NullPath)); + } + + internal AtSpiObjectReference GetRootReference() + { + return new AtSpiObjectReference(_uniqueName, new DBusObjectPath(RootPath)); + } + + internal AtSpiAccessibleCacheItem BuildCacheItem(AtSpiNode node) + { + var self = new AtSpiObjectReference(_uniqueName, new DBusObjectPath(node.Path)); + var app = new AtSpiObjectReference(_uniqueName, new DBusObjectPath(RootPath)); + + var parentPeer = node.InvokeSync(() => node.Peer.GetParent()); + var parentNode = AtSpiNode.TryGet(parentPeer); + var parent = parentNode is not null + ? new AtSpiObjectReference(_uniqueName, new DBusObjectPath(parentNode.Path)) + : new AtSpiObjectReference(string.Empty, new DBusObjectPath(NullPath)); + + var indexInParent = node.InvokeSync(() => + { + var p = node.Peer.GetParent(); + if (p is null) return -1; + var children = p.GetChildren(); + for (var i = 0; i < children.Count; i++) + { + if (ReferenceEquals(children[i], node.Peer)) + return i; + } + return -1; + }); + + var childCount = node.InvokeSync(() => node.Peer.GetChildren().Count); + var interfaces = node.InvokeSync(() => node.GetSupportedInterfaces()) + .OrderBy(static i => i, StringComparer.Ordinal) + .ToList(); + var name = node.InvokeSync(() => node.Peer.GetName()); + var role = (uint)node.InvokeSync(() => AtSpiNode.ToAtSpiRole(node.Peer.GetAutomationControlType())); + var description = node.InvokeSync(() => node.Peer.GetHelpText()); + var states = node.ComputeStates(); + + return new AtSpiAccessibleCacheItem( + self, + app, + parent, + indexInParent, + childCount, + interfaces, + name, + role, + description, + states); + } + + internal void EmitChildrenChanged(AtSpiNode node) + { + if (_a11yConnection is null || _cacheHandler is null) + return; + + // Re-walk the changed subtree to pick up new nodes + var peer = node.Peer; + WalkAndCreateNodes(peer); + BuildHandlers(); + RefreshPathRegistrations(); + + // Emit cache updates + var childPeers = node.InvokeSync(() => peer.GetChildren()); + for (var i = 0; i < childPeers.Count; i++) + { + var childNode = AtSpiNode.GetOrCreate(childPeers[i], this); + if (childNode is not null) + _cacheHandler.EmitAddAccessibleSignal(BuildCacheItem(childNode)); + } + + // Emit children-changed event + if (node.Handlers?.EventObjectHandler is { } eventHandler) + { + var reference = GetReference(node); + var childVariant = new DBusVariant(reference.ToDbusStruct()); + eventHandler.EmitChildrenChangedSignal("add", 0, childVariant); + } + } + + internal void EmitPropertyChange(AtSpiNode node, AutomationPropertyChangedEventArgs e) + { + if (_a11yConnection is null) + return; + + if (node.Handlers?.EventObjectHandler is not { } eventHandler) + return; + + if (e.Property == AutomationElementIdentifiers.NameProperty) + { + eventHandler.EmitPropertyChangeSignal( + "accessible-name", + new DBusVariant(e.NewValue?.ToString() ?? string.Empty)); + } + else if (e.Property == AutomationElementIdentifiers.HelpTextProperty) + { + eventHandler.EmitPropertyChangeSignal( + "accessible-description", + new DBusVariant(e.NewValue?.ToString() ?? string.Empty)); + } + } + + internal void EmitFocusChange(AtSpiNode? focusedNode) + { + if (_a11yConnection is null || focusedNode is null) + return; + + if (focusedNode.Handlers?.EventObjectHandler is { } eventHandler) + { + eventHandler.EmitStateChangedSignal("focused", 1, new DBusVariant(0)); + } + } + + private void WalkAndCreateNodes(AutomationPeer peer) + { + var node = AtSpiNode.GetOrCreate(peer, this); + if (node is not null) + RegisterNode(node); + + var children = node?.InvokeSync(() => peer.GetChildren()); + if (children is null) + return; + + foreach (var child in children) + { + WalkAndCreateNodes(child); + } + } + + private void BuildHandlers() + { + AtSpiNode[] snapshot; + lock (_treeGate) + { + snapshot = _nodesByPath.Values.ToArray(); + } + + foreach (var node in snapshot) + { + if (node.Handlers is not null) + continue; + + var handlers = new AtSpiNodeHandlers(node); + var interfaces = node.InvokeSync(() => node.GetSupportedInterfaces()); + + if (interfaces.Contains(IfaceAccessible)) + handlers.AccessibleHandler = new AtSpiAccessibleHandler(this, node); + + if (interfaces.Contains(IfaceApplication)) + handlers.ApplicationHandler = new AtSpiApplicationHandler(this, node); + + if (interfaces.Contains(IfaceComponent)) + handlers.ComponentHandler = new AtSpiComponentHandler(this, node); + + if (interfaces.Contains(IfaceAction)) + handlers.ActionHandler = new AtSpiActionHandler(this, node); + + if (interfaces.Contains(IfaceValue)) + handlers.ValueHandler = new AtSpiValueHandler(this, node); + + handlers.EventObjectHandler = new AtSpiEventObjectHandler(this, node.Path); + + node.Handlers = handlers; + } + + _cacheHandler ??= new AtSpiCacheHandler(this); + } + + private void RefreshPathRegistrations() + { + if (_a11yConnection is null) + return; + + var desiredRegistrations = new Dictionary Register)>(StringComparer.Ordinal); + + AtSpiNode[] snapshot; + lock (_treeGate) + { + snapshot = _nodesByPath.Values + .OrderBy(static n => n.Path, StringComparer.Ordinal) + .ToArray(); + } + + foreach (var node in snapshot) + { + if (node.Handlers is null) + continue; + + var handlers = node.Handlers; + desiredRegistrations.Add( + node.Path, + (handlers, () => handlers.Register(_a11yConnection))); + } + + if (_cacheHandler is not null) + { + var cacheHandler = _cacheHandler; + desiredRegistrations.Add( + CachePath, + (cacheHandler, () => _a11yConnection.RegisterObjects((DBusObjectPath)CachePath, new object[] { cacheHandler }))); + } + + // Remove stale registrations + foreach (var (path, active) in _pathRegistrations.ToArray()) + { + if (!desiredRegistrations.TryGetValue(path, out var desired) + || !ReferenceEquals(active.Owner, desired.Owner)) + { + active.Registration.Dispose(); + _pathRegistrations.Remove(path); + } + } + + // Add new registrations + foreach (var (path, desired) in desiredRegistrations) + { + if (_pathRegistrations.ContainsKey(path)) + continue; + + var registration = desired.Register(); + _pathRegistrations.Add(path, new ActivePathRegistration(desired.Owner, registration)); + } + } + + private async Task GetAccessibilityBusAddressAsync() + { + try + { + await using var connection = await DBusConnection.ConnectSessionAsync(); + var proxy = new OrgA11yBusProxy(connection, BusNameA11y, new DBusObjectPath(PathA11y)); + return await proxy.GetAddressAsync(); + } + catch + { + return string.Empty; + } + } + + private async Task EmbedApplicationAsync() + { + if (_a11yConnection is null) + return; + + var proxy = new OrgA11yAtspiSocketProxy(_a11yConnection, BusNameRegistry, new DBusObjectPath(RootPath)); + await proxy.EmbedAsync(new AtSpiObjectReference(_uniqueName, new DBusObjectPath(RootPath))); + } + + private void EmitInitialCacheSnapshot() + { + if (_cacheHandler is null) + return; + + AtSpiNode[] snapshot; + lock (_treeGate) + { + snapshot = _nodesByPath.Values + .OrderBy(static node => node.Path, StringComparer.Ordinal) + .ToArray(); + } + + foreach (var node in snapshot) + { + _cacheHandler.EmitAddAccessibleSignal(BuildCacheItem(node)); + } + } + + private sealed class ActivePathRegistration + { + public ActivePathRegistration(object owner, IDisposable registration) + { + Owner = owner; + Registration = registration; + } + + public object Owner { get; } + public IDisposable Registration { get; } + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Avalonia.FreeDesktop.AtSpi.csproj b/src/Avalonia.FreeDesktop.AtSpi/Avalonia.FreeDesktop.AtSpi.csproj new file mode 100644 index 0000000000..34c861691b --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Avalonia.FreeDesktop.AtSpi.csproj @@ -0,0 +1,38 @@ + + + + $(AvsCurrentTargetFramework);$(AvsLegacyTargetFrameworks) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Accessible.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Accessible.xml new file mode 100644 index 0000000000..82c058197b --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Accessible.xml @@ -0,0 +1,883 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Action.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Action.xml new file mode 100644 index 0000000000..fea98fd49c --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Action.xml @@ -0,0 +1,145 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Application.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Application.xml new file mode 100644 index 0000000000..9c2fa225ca --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Application.xml @@ -0,0 +1,86 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Cache.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Cache.xml new file mode 100644 index 0000000000..9949070d75 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Cache.xml @@ -0,0 +1,119 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Component.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Component.xml new file mode 100644 index 0000000000..9a99af72f1 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Component.xml @@ -0,0 +1,346 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/DeviceEventController.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/DeviceEventController.xml new file mode 100644 index 0000000000..9265539268 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/DeviceEventController.xml @@ -0,0 +1,148 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Event.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Event.xml new file mode 100644 index 0000000000..101b51160b --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Event.xml @@ -0,0 +1,799 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Image.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Image.xml new file mode 100644 index 0000000000..04ef025c03 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Image.xml @@ -0,0 +1,87 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Registry.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Registry.xml new file mode 100644 index 0000000000..671088bd47 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Registry.xml @@ -0,0 +1,131 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Socket.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Socket.xml new file mode 100644 index 0000000000..e375eee9ed --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Socket.xml @@ -0,0 +1,102 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Types.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Types.xml new file mode 100644 index 0000000000..1cbe838dd7 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Types.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Value.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Value.xml new file mode 100644 index 0000000000..d2ac5de516 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Value.xml @@ -0,0 +1,47 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/org.a11y.Bus.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/org.a11y.Bus.xml new file mode 100644 index 0000000000..2f39f945be --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/org.a11y.Bus.xml @@ -0,0 +1,9 @@ + + + Types.xml + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiAccessibleHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiAccessibleHandler.cs new file mode 100644 index 0000000000..8f4859a417 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiAccessibleHandler.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + internal sealed class AtSpiAccessibleHandler : IOrgA11yAtspiAccessible + { + private readonly AtSpiServer _server; + private readonly AtSpiNode _node; + + public AtSpiAccessibleHandler(AtSpiServer server, AtSpiNode node) + { + _server = server; + _node = node; + } + + public uint Version => AccessibleVersion; + + public string Name => _node.InvokeSync(() => _node.Peer.GetName()); + + public string Description => _node.InvokeSync(() => _node.Peer.GetHelpText()); + + public AtSpiObjectReference Parent + { + get + { + var parent = _node.InvokeSync(() => _node.Peer.GetParent()); + var parentNode = AtSpiNode.TryGet(parent); + return _server.GetReference(parentNode); + } + } + + public int ChildCount => _node.InvokeSync(() => _node.Peer.GetChildren().Count); + + public string Locale => ResolveLocale(); + + public string AccessibleId => _node.InvokeSync(() => _node.Peer.GetAutomationId() ?? string.Empty); + + public string HelpText => _node.InvokeSync(() => _node.Peer.GetHelpText()); + + public ValueTask GetChildAtIndexAsync(int index) + { + var children = _node.InvokeSync(() => _node.Peer.GetChildren()); + if (index >= 0 && index < children.Count) + { + var childNode = AtSpiNode.GetOrCreate(children[index], _server); + return ValueTask.FromResult(_server.GetReference(childNode)); + } + + return ValueTask.FromResult(_server.GetNullReference()); + } + + public ValueTask> GetChildrenAsync() + { + var children = _node.InvokeSync(() => _node.Peer.GetChildren()); + var refs = new List(children.Count); + foreach (var child in children) + { + var childNode = AtSpiNode.GetOrCreate(child, _server); + refs.Add(_server.GetReference(childNode)); + } + + return ValueTask.FromResult(refs); + } + + public ValueTask GetIndexInParentAsync() + { + var index = _node.InvokeSync(() => + { + var parent = _node.Peer.GetParent(); + if (parent is null) + return -1; + var siblings = parent.GetChildren(); + for (var i = 0; i < siblings.Count; i++) + { + if (ReferenceEquals(siblings[i], _node.Peer)) + return i; + } + + return -1; + }); + return ValueTask.FromResult(index); + } + + public ValueTask> GetRelationSetAsync() + { + return ValueTask.FromResult(new List()); + } + + public ValueTask GetRoleAsync() + { + var role = _node.InvokeSync(() => AtSpiNode.ToAtSpiRole(_node.Peer.GetAutomationControlType())); + return ValueTask.FromResult((uint)role); + } + + public ValueTask GetRoleNameAsync() + { + var role = _node.InvokeSync(() => AtSpiNode.ToAtSpiRole(_node.Peer.GetAutomationControlType())); + return ValueTask.FromResult(AtSpiNode.ToAtSpiRoleName(role)); + } + + public ValueTask GetLocalizedRoleNameAsync() + { + var role = _node.InvokeSync(() => AtSpiNode.ToAtSpiRole(_node.Peer.GetAutomationControlType())); + return ValueTask.FromResult(AtSpiNode.ToAtSpiRoleName(role)); + } + + public ValueTask> GetStateAsync() + { + return ValueTask.FromResult(_node.ComputeStates()); + } + + public ValueTask GetAttributesAsync() + { + return ValueTask.FromResult(new AtSpiAttributeSet()); + } + + public ValueTask GetApplicationAsync() + { + return ValueTask.FromResult(_server.GetRootReference()); + } + + public ValueTask> GetInterfacesAsync() + { + var interfaces = _node.InvokeSync(() => _node.GetSupportedInterfaces()); + var sorted = interfaces.OrderBy(static i => i, StringComparer.Ordinal).ToList(); + return ValueTask.FromResult(sorted); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiActionHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiActionHandler.cs new file mode 100644 index 0000000000..1ec217aaa0 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiActionHandler.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Automation; +using Avalonia.Automation.Provider; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + internal sealed class AtSpiActionHandler : IOrgA11yAtspiAction + { + private readonly AtSpiNode _node; + private readonly List _actions; + + public AtSpiActionHandler(AtSpiServer server, AtSpiNode node) + { + _ = server; + _node = node; + _actions = BuildActionList(); + } + + public uint Version => ActionVersion; + + public int NActions => _actions.Count; + + public ValueTask GetDescriptionAsync(int index) + { + if (index >= 0 && index < _actions.Count) + return ValueTask.FromResult(_actions[index].Description); + return ValueTask.FromResult(string.Empty); + } + + public ValueTask GetNameAsync(int index) + { + if (index >= 0 && index < _actions.Count) + return ValueTask.FromResult(_actions[index].ActionName); + return ValueTask.FromResult(string.Empty); + } + + public ValueTask GetLocalizedNameAsync(int index) + { + if (index >= 0 && index < _actions.Count) + return ValueTask.FromResult(_actions[index].LocalizedName); + return ValueTask.FromResult(string.Empty); + } + + public ValueTask GetKeyBindingAsync(int index) + { + return ValueTask.FromResult(string.Empty); + } + + public ValueTask> GetActionsAsync() + { + var result = new List(_actions.Count); + foreach (var entry in _actions) + result.Add(new AtSpiAction(entry.LocalizedName, entry.Description, string.Empty)); + return ValueTask.FromResult(result); + } + + public ValueTask DoActionAsync(int index) + { + if (index < 0 || index >= _actions.Count) + return ValueTask.FromResult(false); + + var action = _actions[index]; + _node.InvokeSync(() => ExecuteAction(action.ActionName)); + return ValueTask.FromResult(true); + } + + private void ExecuteAction(string actionName) + { + switch (actionName) + { + case "click": + _node.Peer.GetProvider()?.Invoke(); + break; + case "toggle": + _node.Peer.GetProvider()?.Toggle(); + break; + case "expand": + _node.Peer.GetProvider()?.Expand(); + break; + case "collapse": + _node.Peer.GetProvider()?.Collapse(); + break; + } + } + + private List BuildActionList() + { + var actions = new List(); + + if (_node.Peer.GetProvider() is not null) + actions.Add(new ActionEntry("click", "Click", "Performs the default action")); + + if (_node.Peer.GetProvider() is not null) + actions.Add(new ActionEntry("toggle", "Toggle", "Toggles the control state")); + + if (_node.Peer.GetProvider() is { } expandCollapse) + { + if (expandCollapse.ExpandCollapseState == ExpandCollapseState.Collapsed) + actions.Add(new ActionEntry("expand", "Expand", "Expands the control")); + else + actions.Add(new ActionEntry("collapse", "Collapse", "Collapses the control")); + } + + return actions; + } + + private readonly record struct ActionEntry(string ActionName, string LocalizedName, string Description); + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiApplicationHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiApplicationHandler.cs new file mode 100644 index 0000000000..f392a538f5 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiApplicationHandler.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + internal sealed class AtSpiApplicationHandler : IOrgA11yAtspiApplication + { + public AtSpiApplicationHandler(AtSpiServer server, AtSpiNode node) + { + _ = server; + _ = node; + var version = ResolveToolkitVersion(); + ToolkitName = "Avalonia"; + Version = version; + ToolkitVersion = version; + AtspiVersion = "2.1"; + InterfaceVersion = ApplicationVersion; + } + + public string ToolkitName { get; } + public string Version { get; } + public string ToolkitVersion { get; } + public string AtspiVersion { get; } + public uint InterfaceVersion { get; } + + public int Id { get; set; } + + public ValueTask GetLocaleAsync(uint lctype) + { + return ValueTask.FromResult(ResolveLocale()); + } + + public ValueTask GetApplicationBusAddressAsync() + { + return ValueTask.FromResult(string.Empty); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiComponentHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiComponentHandler.cs new file mode 100644 index 0000000000..5e89d3d687 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiComponentHandler.cs @@ -0,0 +1,219 @@ +using System.Threading.Tasks; +using Avalonia.Automation.Provider; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using Avalonia.Platform; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + internal sealed class AtSpiComponentHandler : IOrgA11yAtspiComponent + { + private readonly AtSpiServer _server; + private readonly AtSpiNode _node; + + public AtSpiComponentHandler(AtSpiServer server, AtSpiNode node) + { + _server = server; + _node = node; + } + + public uint Version => ComponentVersion; + + public ValueTask ContainsAsync(int x, int y, uint coordType) + { + var rect = GetScreenExtents(); + var point = TranslateToScreen(x, y, coordType); + var contains = point.x >= rect.X && point.y >= rect.Y && + point.x < rect.X + rect.Width && point.y < rect.Y + rect.Height; + return ValueTask.FromResult(contains); + } + + public ValueTask GetAccessibleAtPointAsync(int x, int y, uint coordType) + { + // Basic hit testing - return self for now + var rect = GetScreenExtents(); + var point = TranslateToScreen(x, y, coordType); + if (point.x >= rect.X && point.y >= rect.Y && + point.x < rect.X + rect.Width && point.y < rect.Y + rect.Height) + { + return ValueTask.FromResult(_server.GetReference(_node)); + } + + return ValueTask.FromResult(_server.GetNullReference()); + } + + public ValueTask GetExtentsAsync(uint coordType) + { + var rect = GetScreenExtents(); + var translated = TranslateRect(rect, coordType); + return ValueTask.FromResult(new AtSpiRect( + (int)translated.X, (int)translated.Y, + (int)translated.Width, (int)translated.Height)); + } + + public ValueTask<(int X, int Y)> GetPositionAsync(uint coordType) + { + var rect = GetScreenExtents(); + var translated = TranslateRect(rect, coordType); + return ValueTask.FromResult(((int)translated.X, (int)translated.Y)); + } + + public ValueTask<(int Width, int Height)> GetSizeAsync() + { + var rect = GetScreenExtents(); + return ValueTask.FromResult(((int)rect.Width, (int)rect.Height)); + } + + public ValueTask GetLayerAsync() + { + var controlType = _node.InvokeSync(() => _node.Peer.GetAutomationControlType()); + // Window layer = 7, Widget layer = 3 + var layer = controlType == Automation.Peers.AutomationControlType.Window ? 7u : 3u; + return ValueTask.FromResult(layer); + } + + public ValueTask GetMDIZOrderAsync() => ValueTask.FromResult((short)-1); + + public ValueTask GrabFocusAsync() + { + _node.InvokeSync(() => _node.Peer.SetFocus()); + return ValueTask.FromResult(true); + } + + public ValueTask GetAlphaAsync() => ValueTask.FromResult(1.0); + + public ValueTask SetExtentsAsync(int x, int y, int width, int height, uint coordType) + { + return ValueTask.FromResult(false); + } + + public ValueTask SetPositionAsync(int x, int y, uint coordType) + { + return ValueTask.FromResult(false); + } + + public ValueTask SetSizeAsync(int width, int height) + { + return ValueTask.FromResult(false); + } + + public ValueTask ScrollToAsync(uint type) + { + return ValueTask.FromResult(false); + } + + public ValueTask ScrollToPointAsync(uint coordType, int x, int y) + { + return ValueTask.FromResult(false); + } + + private Rect GetScreenExtents() + { + return _node.InvokeSync(() => + { + var bounds = _node.Peer.GetBoundingRectangle(); + if (_node is RootAtSpiNode rootNode) + return rootNode.ToScreen(bounds); + + // Find root and translate to screen + var root = _node.Peer.GetVisualRoot(); + if (root is not null) + { + var rootNode2 = AtSpiNode.TryGet(root) as RootAtSpiNode; + if (rootNode2 is not null) + return rootNode2.ToScreen(bounds); + } + + return bounds; + }); + } + + private Rect TranslateRect(Rect screenRect, uint coordType) + { + // coordType: 0 = screen, 1 = window, 2 = parent + if (coordType == 0) + return screenRect; + + if (coordType == 1) + { + var windowRect = GetWindowRect(); + return new Rect( + screenRect.X - windowRect.X, + screenRect.Y - windowRect.Y, + screenRect.Width, + screenRect.Height); + } + + if (coordType == 2) + { + var parentRect = GetParentScreenRect(); + return new Rect( + screenRect.X - parentRect.X, + screenRect.Y - parentRect.Y, + screenRect.Width, + screenRect.Height); + } + + return screenRect; + } + + private (int x, int y) TranslateToScreen(int x, int y, uint coordType) + { + if (coordType == 0) + return (x, y); + + if (coordType == 1) + { + var windowRect = GetWindowRect(); + return (x + (int)windowRect.X, y + (int)windowRect.Y); + } + + if (coordType == 2) + { + var parentRect = GetParentScreenRect(); + return (x + (int)parentRect.X, y + (int)parentRect.Y); + } + + return (x, y); + } + + private Rect GetWindowRect() + { + return _node.InvokeSync(() => + { + var root = _node.Peer.GetVisualRoot(); + if (root is not null) + { + var rootNode = AtSpiNode.TryGet(root) as RootAtSpiNode; + if (rootNode is not null) + return rootNode.ToScreen(root.GetBoundingRectangle()); + } + + return default; + }); + } + + private Rect GetParentScreenRect() + { + return _node.InvokeSync(() => + { + var parent = _node.Peer.GetParent(); + if (parent is not null) + { + var bounds = parent.GetBoundingRectangle(); + var root = parent.GetVisualRoot(); + if (root is not null) + { + var rootNode = AtSpiNode.TryGet(root) as RootAtSpiNode; + if (rootNode is not null) + return rootNode.ToScreen(bounds); + } + + return bounds; + } + + return default; + }); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEventObjectHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEventObjectHandler.cs new file mode 100644 index 0000000000..d2b1d09fc4 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEventObjectHandler.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + internal sealed class AtSpiEventObjectHandler : IOrgA11yAtspiEventObject + { + private readonly AtSpiServer _server; + private readonly string _path; + + public AtSpiEventObjectHandler(AtSpiServer server, string path) + { + _server = server; + _path = path; + } + + public uint Version => EventObjectVersion; + + public void EmitChildrenChangedSignal(string operation, int indexInParent, DBusVariant child) + { + EmitSignal("ChildrenChanged", operation, indexInParent, 0, child, EmptyProperties()); + } + + public void EmitPropertyChangeSignal(string propertyName, DBusVariant value) + { + EmitSignal("PropertyChange", propertyName, 0, 0, value, EmptyProperties()); + } + + public void EmitStateChangedSignal(string stateName, int detail1, DBusVariant value) + { + EmitSignal("StateChanged", stateName, detail1, 0, value, EmptyProperties()); + } + + private void EmitSignal(string member, params object[] body) + { + var connection = _server.A11yConnection; + if (connection is null) + return; + + var message = DBusMessage.CreateSignal( + (DBusObjectPath)_path, + IfaceEventObject, + member, + body); + + _ = connection.SendMessageAsync(message); + } + + private static Dictionary EmptyProperties() + { + return new Dictionary(StringComparer.Ordinal); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiValueHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiValueHandler.cs new file mode 100644 index 0000000000..20879a7753 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiValueHandler.cs @@ -0,0 +1,41 @@ +using System; +using Avalonia.Automation.Provider; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + internal sealed class AtSpiValueHandler : IOrgA11yAtspiValue + { + private readonly AtSpiNode _node; + + public AtSpiValueHandler(AtSpiServer server, AtSpiNode node) + { + _ = server; + _node = node; + } + + public uint Version => ValueVersion; + + public double MinimumValue => _node.InvokeSync(p => p.Minimum); + + public double MaximumValue => _node.InvokeSync(p => p.Maximum); + + public double MinimumIncrement => _node.InvokeSync(p => p.SmallChange); + + public string Text => string.Empty; + + public double CurrentValue + { + get => _node.InvokeSync(p => p.Value); + set + { + _node.InvokeSync(p => + { + var clamped = Math.Max(p.Minimum, Math.Min(p.Maximum, value)); + p.SetValue(clamped); + }); + } + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/RootAtSpiNode.cs b/src/Avalonia.FreeDesktop.AtSpi/RootAtSpiNode.cs new file mode 100644 index 0000000000..1137941433 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/RootAtSpiNode.cs @@ -0,0 +1,46 @@ +using System; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.Platform; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + internal sealed class RootAtSpiNode : AtSpiNode + { + public RootAtSpiNode(AutomationPeer peer, AtSpiServer server) + : base(peer, server, RootPath) + { + RootProvider = peer.GetProvider() ?? throw new InvalidOperationException( + "Attempt to create RootAtSpiNode from peer which does not implement IRootProvider."); + RootProvider.FocusChanged += OnRootFocusChanged; + } + + public IRootProvider RootProvider { get; } + public IWindowBaseImpl? WindowImpl => RootProvider.PlatformImpl as IWindowBaseImpl; + + public Rect ToScreen(Rect rect) + { + if (WindowImpl is null) + return default; + return new PixelRect( + WindowImpl.PointToScreen(rect.TopLeft), + WindowImpl.PointToScreen(rect.BottomRight)) + .ToRect(1); + } + + public Point PointToClient(PixelPoint point) + { + if (WindowImpl is null) + return default; + return WindowImpl.PointToClient(point); + } + + private void OnRootFocusChanged(object? sender, EventArgs e) + { + var focused = InvokeSync(() => RootProvider.GetFocus()); + var focusedNode = focused is not null ? GetOrCreate(focused, Server) : null; + Server.EmitFocusChange(focusedNode); + } + } +}