diff --git a/.gitmodules b/.gitmodules index 07f532607a..d1463ad26b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "XamlX"] path = external/XamlX url = https://github.com/kekekeks/XamlX.git +[submodule "Avalonia.DBus"] + path = external/Avalonia.DBus + url = https://github.com/AvaloniaUI/Avalonia.DBus.git diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index 3e2fa2ba77..c3331ebe40 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -24,6 +24,7 @@ "src\\Avalonia.Desktop\\Avalonia.Desktop.csproj", "src\\Avalonia.Dialogs\\Avalonia.Dialogs.csproj", "src\\Avalonia.Fonts.Inter\\Avalonia.Fonts.Inter.csproj", + "src\\Avalonia.FreeDesktop.AtSpi\\Avalonia.FreeDesktop.AtSpi.csproj", "src\\Avalonia.FreeDesktop\\Avalonia.FreeDesktop.csproj", "src\\Avalonia.Metal\\Avalonia.Metal.csproj", "src\\Avalonia.MicroCom\\Avalonia.MicroCom.csproj", @@ -46,6 +47,7 @@ "src\\tools\\Avalonia.Analyzers.CodeFixes.CSharp\\Avalonia.Analyzers.CodeFixes.CSharp.csproj", "src\\tools\\Avalonia.Analyzers.CSharp\\Avalonia.Analyzers.CSharp.csproj", "src\\tools\\Avalonia.Analyzers.VisualBasic\\Avalonia.Analyzers.VisualBasic.csproj", + "src\\tools\\Avalonia.DBus.Generators\\Avalonia.DBus.Generators.csproj", "src\\tools\\Avalonia.Generators\\Avalonia.Generators.csproj", "src\\tools\\DevAnalyzers\\DevAnalyzers.csproj", "src\\tools\\DevGenerators\\DevGenerators.csproj", diff --git a/Avalonia.sln b/Avalonia.sln index 84b50a87fa..b4b89de2a3 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -283,6 +283,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Analyzers.CodeFixe EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Analyzers.VisualBasic", "src\tools\Avalonia.Analyzers.VisualBasic\Avalonia.Analyzers.VisualBasic.csproj", "{A7644C3B-B843-44F1-9940-560D56CB0936}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.FreeDesktop.AtSpi", "src\Avalonia.FreeDesktop.AtSpi\Avalonia.FreeDesktop.AtSpi.csproj", "{742C3613-514C-4D6B-804A-2A7925F278F3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.DBus.Generators", "src\tools\Avalonia.DBus.Generators\Avalonia.DBus.Generators.csproj", "{98A16FFD-0C99-4665-AC64-DC17E86879A2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -661,14 +665,22 @@ Global {11522B0D-BF31-42D5-8FC5-41E58F319AF9}.Debug|Any CPU.Build.0 = Debug|Any CPU {11522B0D-BF31-42D5-8FC5-41E58F319AF9}.Release|Any CPU.ActiveCfg = Release|Any CPU {11522B0D-BF31-42D5-8FC5-41E58F319AF9}.Release|Any CPU.Build.0 = Release|Any CPU - {A7644C3B-B843-44F1-9940-560D56CB0936}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A7644C3B-B843-44F1-9940-560D56CB0936}.Release|Any CPU.Build.0 = Release|Any CPU - {A7644C3B-B843-44F1-9940-560D56CB0936}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {A7644C3B-B843-44F1-9940-560D56CB0936}.Debug|Any CPU.Build.0 = Release|Any CPU - {FDFB9C25-552D-420B-9D4A-DB0BB6472239}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FDFB9C25-552D-420B-9D4A-DB0BB6472239}.Release|Any CPU.Build.0 = Release|Any CPU {FDFB9C25-552D-420B-9D4A-DB0BB6472239}.Debug|Any CPU.ActiveCfg = Release|Any CPU {FDFB9C25-552D-420B-9D4A-DB0BB6472239}.Debug|Any CPU.Build.0 = Release|Any CPU + {FDFB9C25-552D-420B-9D4A-DB0BB6472239}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDFB9C25-552D-420B-9D4A-DB0BB6472239}.Release|Any CPU.Build.0 = Release|Any CPU + {A7644C3B-B843-44F1-9940-560D56CB0936}.Debug|Any CPU.ActiveCfg = Release|Any CPU + {A7644C3B-B843-44F1-9940-560D56CB0936}.Debug|Any CPU.Build.0 = Release|Any CPU + {A7644C3B-B843-44F1-9940-560D56CB0936}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7644C3B-B843-44F1-9940-560D56CB0936}.Release|Any CPU.Build.0 = Release|Any CPU + {742C3613-514C-4D6B-804A-2A7925F278F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {742C3613-514C-4D6B-804A-2A7925F278F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {742C3613-514C-4D6B-804A-2A7925F278F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {742C3613-514C-4D6B-804A-2A7925F278F3}.Release|Any CPU.Build.0 = Release|Any CPU + {98A16FFD-0C99-4665-AC64-DC17E86879A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98A16FFD-0C99-4665-AC64-DC17E86879A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98A16FFD-0C99-4665-AC64-DC17E86879A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98A16FFD-0C99-4665-AC64-DC17E86879A2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -754,8 +766,10 @@ Global {342D2657-2F84-493C-B74B-9D2CAE5D9DAB} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {26918642-829D-4FA2-B60A-BE8D83F4E063} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {11522B0D-BF31-42D5-8FC5-41E58F319AF9} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} - {A7644C3B-B843-44F1-9940-560D56CB0936} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} {FDFB9C25-552D-420B-9D4A-DB0BB6472239} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} + {A7644C3B-B843-44F1-9940-560D56CB0936} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} + {742C3613-514C-4D6B-804A-2A7925F278F3} = {86C53C40-57AA-45B8-AD42-FAE0EFDF0F2B} + {98A16FFD-0C99-4665-AC64-DC17E86879A2} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/external/Avalonia.DBus b/external/Avalonia.DBus new file mode 160000 index 0000000000..f91a822c25 --- /dev/null +++ b/external/Avalonia.DBus @@ -0,0 +1 @@ +Subproject commit f91a822c258476f185e51112388775591e6ef9d6 diff --git a/src/Avalonia.FreeDesktop.AtSpi/ApplicationAccessibleHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/ApplicationAccessibleHandler.cs new file mode 100644 index 0000000000..d5a16c44b9 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/ApplicationAccessibleHandler.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.DBusXml; + +namespace Avalonia.FreeDesktop.AtSpi; + +/// +/// implementation for . +/// +internal sealed class ApplicationAccessibleHandler(AtSpiServer server, ApplicationAtSpiNode appNode) + : IOrgA11yAtspiAccessible +{ + private static readonly List s_interfaces = + [ + AtSpiConstants.IfaceAccessible, + AtSpiConstants.IfaceApplication, + ]; + + public uint Version => AtSpiConstants.AccessibleVersion; + public string Name => appNode.Name; + public string Description => string.Empty; + + public AtSpiObjectReference Parent => + new(string.Empty, new DBusObjectPath(AtSpiConstants.NullPath)); + + public int ChildCount => appNode.WindowChildren.Count; + public string Locale => AtSpiConstants.ResolveLocale(); + public string AccessibleId => string.Empty; + public string HelpText => string.Empty; + + public ValueTask GetChildAtIndexAsync(int index) + { + var children = appNode.WindowChildren; + if (index >= 0 && index < children.Count) + return ValueTask.FromResult(server.GetReference(children[index])); + return ValueTask.FromResult(server.GetNullReference()); + } + + public ValueTask> GetChildrenAsync() + { + var children = appNode.WindowChildren; + var refs = new List(children.Count); + foreach (var child in children) + refs.Add(server.GetReference(child)); + return ValueTask.FromResult(refs); + } + + public ValueTask GetIndexInParentAsync() => ValueTask.FromResult(-1); + + public ValueTask> GetRelationSetAsync() => + ValueTask.FromResult(new List()); + + public ValueTask GetRoleAsync() => ValueTask.FromResult((uint)AtSpiRole.Application); + + public ValueTask GetRoleNameAsync() => ValueTask.FromResult("application"); + + public ValueTask GetLocalizedRoleNameAsync() => ValueTask.FromResult("application"); + + public ValueTask> GetStateAsync() => + ValueTask.FromResult(AtSpiConstants.BuildStateSet([AtSpiState.Active])); + + public ValueTask GetAttributesAsync() => + ValueTask.FromResult(new AtSpiAttributeSet()); + + public ValueTask GetApplicationAsync() => + ValueTask.FromResult(server.GetRootReference()); + + public ValueTask> GetInterfacesAsync() => + ValueTask.FromResult(s_interfaces.ToList()); +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/ApplicationAtSpiNode.cs b/src/Avalonia.FreeDesktop.AtSpi/ApplicationAtSpiNode.cs new file mode 100644 index 0000000000..86fb488200 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/ApplicationAtSpiNode.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Avalonia.FreeDesktop.AtSpi.Handlers; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + /// + /// Synthetic application root node that is not backed by an . + /// Registered at /org/a11y/atspi/accessible/root and serves as the AT-SPI tree root. + /// + internal sealed class ApplicationAtSpiNode(string? applicationName) + { + private readonly List _windowChildren = []; + + public string Path => RootPath; + public string Name { get; } = applicationName + ?? Application.Current?.Name + ?? Process.GetCurrentProcess().ProcessName; + + public AtSpiRole Role => AtSpiRole.Application; + public List WindowChildren => _windowChildren; + + public void AddWindowChild(RootAtSpiNode windowNode) => _windowChildren.Add(windowNode); + public void RemoveWindowChild(RootAtSpiNode windowNode) => _windowChildren.Remove(windowNode); + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/ApplicationNodeApplicationHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/ApplicationNodeApplicationHandler.cs new file mode 100644 index 0000000000..336bf1287d --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/ApplicationNodeApplicationHandler.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using Avalonia.FreeDesktop.AtSpi.DBusXml; + +namespace Avalonia.FreeDesktop.AtSpi; + +/// +/// implementation for . +/// +internal sealed class ApplicationNodeApplicationHandler : IOrgA11yAtspiApplication +{ + public ApplicationNodeApplicationHandler() + { + var version = AtSpiConstants.ResolveToolkitVersion(); + ToolkitName = "Avalonia"; + Version = version; + ToolkitVersion = version; + AtspiVersion = "2.1"; + InterfaceVersion = AtSpiConstants.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) => + ValueTask.FromResult(AtSpiConstants.ResolveLocale()); + + public ValueTask GetApplicationBusAddressAsync() => + ValueTask.FromResult(string.Empty); +} \ No newline at end of file diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiAccessibilityWatcher.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiAccessibilityWatcher.cs new file mode 100644 index 0000000000..9f40e523f7 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiAccessibilityWatcher.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading.Tasks; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using Avalonia.Logging; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + /// + /// Monitors the session bus to detect whether a screen reader is active. + /// + internal sealed class AtSpiAccessibilityWatcher : IAsyncDisposable + { + private DBusConnection? _sessionConnection; + private IDisposable? _propertiesWatcher; + + public bool IsEnabled { get; private set; } + public event EventHandler? IsEnabledChanged; + + public async Task InitAsync() + { + try + { + _sessionConnection = await DBusConnection.ConnectSessionAsync(); + var proxy = new OrgA11yStatusProxy( + _sessionConnection, BusNameA11y, new DBusObjectPath(PathA11y)); + + try + { + var props = await proxy.GetAllPropertiesAsync(); + IsEnabled = props.IsEnabled || props.ScreenReaderEnabled; + } + catch (Exception e) + { + Logger.TryGet(LogEventLevel.Debug, LogArea.FreeDesktopPlatform)? + .Log(this, "AT-SPI status properties query failed, defaulting to disabled: {Exception}", e); + IsEnabled = false; + } + + _propertiesWatcher = await proxy.WatchPropertiesChangedAsync( + (changed, _, _) => + { + var enabled = changed.IsEnabled || changed.ScreenReaderEnabled; + if (enabled == IsEnabled) return; + IsEnabled = enabled; + IsEnabledChanged?.Invoke(this, enabled); + }, + sender: null, + emitOnCapturedContext: true); + } + catch (Exception e) + { + // D-Bus session bus unavailable or org.a11y.Bus not present. + // Silently degrade - accessibility remains disabled. + Logger.TryGet(LogEventLevel.Debug, LogArea.FreeDesktopPlatform)? + .Log(this, "AT-SPI watcher unavailable; accessibility remains disabled: {Exception}", e); + IsEnabled = false; + } + } + + public async ValueTask DisposeAsync() + { + _propertiesWatcher?.Dispose(); + _propertiesWatcher = null; + + if (_sessionConnection is not null) + { + await _sessionConnection.DisposeAsync(); + _sessionConnection = null; + } + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiCacheHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiCacheHandler.cs new file mode 100644 index 0000000000..4356ac1f0d --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiCacheHandler.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + /// + /// Registers a dummy AT-SPI cache interface at /org/a11y/atspi/cache. + /// + internal sealed class AtSpiCacheHandler : IOrgA11yAtspiCache + { + public uint Version => CacheVersion; + + public ValueTask> GetItemsAsync() + { + return ValueTask.FromResult(new List()); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiConstants.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiConstants.cs new file mode 100644 index 0000000000..3c7343c5f8 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiConstants.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace Avalonia.FreeDesktop.AtSpi +{ + /// + /// Well-known AT-SPI2 D-Bus paths, interface names, and utility methods. + /// + 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 = "/net/avaloniaui/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 IfaceEventWindow = "org.a11y.atspi.Event.Window"; + internal const string IfaceCache = "org.a11y.atspi.Cache"; + internal const string IfaceSelection = "org.a11y.atspi.Selection"; + internal const string IfaceImage = "org.a11y.atspi.Image"; + internal const string IfaceText = "org.a11y.atspi.Text"; + internal const string IfaceEditableText = "org.a11y.atspi.EditableText"; + internal const string IfaceCollection = "org.a11y.atspi.Collection"; + + // 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 EventWindowVersion = 1; + internal const uint CacheVersion = 1; + internal const uint ImageVersion = 1; + internal const uint SelectionVersion = 1; + internal const uint TextVersion = 1; + internal const uint EditableTextVersion = 1; + internal const uint CollectionVersion = 1; + + internal const uint WidgetLayer = 3; + internal const uint WindowLayer = 7; + + internal static List BuildStateSet(IReadOnlyCollection? states) + { + if (states == null || states.Count == 0) + return [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 [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() + { + // TODO: Better way of doing this? + return typeof(AtSpiConstants).Assembly.GetName().Version?.ToString() ?? "0"; + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiCoordType.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiCoordType.cs new file mode 100644 index 0000000000..d73c7862e4 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiCoordType.cs @@ -0,0 +1,8 @@ +namespace Avalonia.FreeDesktop.AtSpi; + +internal enum AtSpiCoordType : uint +{ + Screen = 0, + Window = 1, + Parent = 2, +} \ No newline at end of file diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.RoleMapping.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.RoleMapping.cs new file mode 100644 index 0000000000..db1746acfe --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.RoleMapping.cs @@ -0,0 +1,107 @@ +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; + +namespace Avalonia.FreeDesktop.AtSpi +{ + internal partial class AtSpiNode + { + public static AtSpiRole ToAtSpiRole(AutomationControlType controlType, AutomationPeer? peer = null) + { + return controlType switch + { + AutomationControlType.None => AtSpiRole.Panel, + AutomationControlType.Button => peer?.GetProvider() is not null + ? AtSpiRole.ToggleButton + : 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.ToggleButton => "toggle 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..f2001bce2b --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.StateMapping.cs @@ -0,0 +1,111 @@ +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 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; + case ToggleState.Off: + 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; + } + } + + // Selection item states + if (Peer.GetProvider() is { } selectionItem) + { + states.Add(AtSpiState.Selectable); + if (selectionItem.IsSelected) + states.Add(AtSpiState.Selected); + } + + // Multi-selectable container + if (Peer.GetProvider() is { CanSelectMultiple: true }) + states.Add(AtSpiState.MultiSelectable); + + // Value provider states (text editable/read-only) + if (Peer.GetProvider() is { } valueProvider) + { + if (valueProvider.IsReadOnly) + states.Add(AtSpiState.ReadOnly); + else + states.Add(AtSpiState.Editable); + } + + // Range value read-only + if (Peer.GetProvider() is { IsReadOnly: true }) + states.Add(AtSpiState.ReadOnly); + + // Required for form + if (Peer is ControlAutomationPeer controlPeer && + AutomationProperties.GetIsRequiredForForm(controlPeer.Owner)) + states.Add(AtSpiState.Required); + + // Window-level active state and text entry states + var controlType = Peer.GetAutomationControlType(); + if (controlType == AutomationControlType.Window) + states.Add(AtSpiState.Active); + + if (controlType == AutomationControlType.Edit) + states.Add(AtSpiState.SingleLine); + + 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..dc099464a6 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiNode.cs @@ -0,0 +1,359 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.Handlers; +using Avalonia.Logging; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + /// + /// Represents an element in the AT-SPI tree, backed by an AutomationPeer. + /// + internal partial class AtSpiNode + { + private protected bool _detached; + private bool _attached; + private bool _childrenDirty = true; + private List _attachedChildren = []; + + private readonly string _path; + + protected AtSpiNode(AutomationPeer peer, AtSpiServer server) + { + Peer = peer; + Server = server; + _path = server.AllocateNodePath(); + } + + public AutomationPeer Peer { get; } + public AtSpiServer Server { get; } + public string Path => _path; + internal bool IsAttached => _attached && !_detached; + internal AtSpiNode? Parent { get; private set; } + internal IReadOnlyList AttachedChildren => _attachedChildren; + internal Task? PathRegistrationTask { get; private set; } + + public HashSet GetSupportedInterfaces() + { + var interfaces = new HashSet(StringComparer.Ordinal) { IfaceAccessible, IfaceComponent }; + if (ApplicationHandler is not null) interfaces.Add(IfaceApplication); + if (ActionHandler is not null) interfaces.Add(IfaceAction); + if (ValueHandler is not null) interfaces.Add(IfaceValue); + if (SelectionHandler is not null) interfaces.Add(IfaceSelection); + if (TextHandler is not null) interfaces.Add(IfaceText); + if (EditableTextHandler is not null) interfaces.Add(IfaceEditableText); + if (ImageHandler is not null) interfaces.Add(IfaceImage); + return interfaces; + } + + internal AtSpiAccessibleHandler? AccessibleHandler { get; private set; } + internal ApplicationNodeApplicationHandler? ApplicationHandler { get; private set; } + internal AtSpiComponentHandler? ComponentHandler { get; private set; } + internal AtSpiActionHandler? ActionHandler { get; private set; } + internal AtSpiValueHandler? ValueHandler { get; private set; } + internal AtSpiSelectionHandler? SelectionHandler { get; private set; } + internal AtSpiTextHandler? TextHandler { get; private set; } + internal AtSpiEditableTextHandler? EditableTextHandler { get; private set; } + internal AtSpiImageHandler? ImageHandler { get; private set; } + internal AtSpiEventObjectHandler? EventObjectHandler { get; private set; } + internal AtSpiEventWindowHandler? EventWindowHandler { get; private set; } + + internal void BuildAndRegisterHandlers( + IDBusConnection connection, + SynchronizationContext? synchronizationContext = null) + { + var previousRegistrationTask = PathRegistrationTask; + + var targets = new List(); + + // Accessible - always present + targets.Add(AccessibleHandler = new AtSpiAccessibleHandler(Server, this)); + + if (Peer.GetProvider() is not null) + targets.Add(ApplicationHandler = new ApplicationNodeApplicationHandler()); + + // Component - all visual elements + targets.Add(ComponentHandler = new AtSpiComponentHandler(Server, this)); + + if (Peer.GetProvider() is not null || + Peer.GetProvider() is not null || + Peer.GetProvider() is not null || + Peer.GetProvider() is not null || + Peer.GetProvider() is not null) + { + targets.Add(ActionHandler = new AtSpiActionHandler(Server, this)); + } + + if (Peer.GetProvider() is not null) + targets.Add(ValueHandler = new AtSpiValueHandler(Server, this)); + + if (Peer.GetProvider() is not null) + targets.Add(SelectionHandler = new AtSpiSelectionHandler(Server, this)); + + if (Peer.GetProvider() is { } valueProvider + && Peer.GetProvider() is null) + { + targets.Add(TextHandler = new AtSpiTextHandler(this)); + + if (!valueProvider.IsReadOnly) + targets.Add(EditableTextHandler = new AtSpiEditableTextHandler(this)); + } + + if (Peer.GetAutomationControlType() == AutomationControlType.Image) + targets.Add(ImageHandler = new AtSpiImageHandler(Server, this)); + + // Event handlers - always present + targets.Add(EventObjectHandler = new AtSpiEventObjectHandler(Server, Path)); + + if (this is RootAtSpiNode) + targets.Add(EventWindowHandler = new AtSpiEventWindowHandler(Server, Path)); + + PathRegistrationTask = ReplacePathRegistrationAsync( + previousRegistrationTask, + connection, + targets, + synchronizationContext); + } + + internal static AtSpiNode Create(AutomationPeer peer, AtSpiServer server) + { + return peer.GetProvider() is not null + ? new RootAtSpiNode(peer, server) + : new AtSpiNode(peer, server); + } + + internal static string GetAccessibleName(AutomationPeer peer) + { + var name = peer.GetName(); + if (!string.IsNullOrWhiteSpace(name)) + return name; + + var visualTypeName = peer.GetClassName(); + return string.IsNullOrWhiteSpace(visualTypeName) ? string.Empty : visualTypeName; + } + + internal void Attach(AtSpiNode? parent) + { + if (_detached) + return; + + if (_attached) + { + Parent = parent; + return; + } + + _attached = true; + _childrenDirty = true; + Parent = parent; + Peer.ChildrenChanged += OnPeerChildrenChanged; + Peer.PropertyChanged += OnPeerPropertyChanged; + + if (Server.A11yConnection is { } connection) + BuildAndRegisterHandlers(connection, Server.SyncContext); + } + + internal void SetParent(AtSpiNode? parent) => Parent = parent; + + internal bool RemoveAttachedChild(AtSpiNode child) => _attachedChildren.Remove(child); + + internal IReadOnlyList EnsureChildren() + { + if (!IsAttached) + return Array.Empty(); + + if (!_childrenDirty) + return _attachedChildren; + + var childPeers = Peer.GetChildren(); + var nextChildren = new List(childPeers.Count); + var nextChildrenSet = new HashSet(); + foreach (var childPeer in childPeers) + { + var childNode = Server.GetOrCreateNode(childPeer); + if (!Server.AttachNode(childNode, this)) + continue; + + nextChildren.Add(childNode); + nextChildrenSet.Add(childNode); + } + + if (_attachedChildren.Count > 0) + { + var removed = _attachedChildren.Where(c => !nextChildrenSet.Contains(c)).ToArray(); + foreach (var removedNode in removed) + { + if (ReferenceEquals(removedNode.Parent, this)) + Server.DetachSubtreeRecursive(removedNode); + } + } + + _attachedChildren = nextChildren; + _childrenDirty = false; + return _attachedChildren; + } + + public virtual void Detach() + { + if (_detached) + return; + + _detached = true; + _attached = false; + _childrenDirty = true; + _attachedChildren.Clear(); + Parent = null; + Peer.ChildrenChanged -= OnPeerChildrenChanged; + Peer.PropertyChanged -= OnPeerPropertyChanged; + DisposePathRegistration(); + } + + internal async Task DisposePathRegistrationAsync() + { + var registrationTask = PathRegistrationTask; + PathRegistrationTask = null; + await DisposeRegistrationAsync(registrationTask).ConfigureAwait(false); + } + + internal void DisposePathRegistration() + { + var registrationTask = PathRegistrationTask; + PathRegistrationTask = null; + + if (registrationTask is null) + return; + + if (registrationTask.IsCompletedSuccessfully) + { + registrationTask.Result.Dispose(); + return; + } + + _ = DisposeRegistrationAsync(registrationTask); + } + + private async Task ReplacePathRegistrationAsync( + Task? previousRegistrationTask, + IDBusConnection connection, + IReadOnlyCollection targets, + SynchronizationContext? synchronizationContext) + { + await DisposeRegistrationAsync(previousRegistrationTask).ConfigureAwait(false); + return await connection.RegisterObjects((DBusObjectPath)Path, targets, synchronizationContext) + .ConfigureAwait(false); + } + + private static async Task DisposeRegistrationAsync(Task? registrationTask) + { + if (registrationTask is null) + return; + + try + { + var registration = await registrationTask.ConfigureAwait(false); + registration.Dispose(); + } + catch (Exception e) + { + // Best-effort cleanup: path may have failed to register or connection may be gone. + Logger.TryGet(LogEventLevel.Debug, LogArea.FreeDesktopPlatform)? + .Log(null, "AT-SPI node path registration cleanup failed: {Exception}", e); + } + } + + private void OnPeerChildrenChanged(object? sender, EventArgs e) + { + if (Server.A11yConnection is null || !IsAttached) + return; + + _childrenDirty = true; + + var childPeers = Peer.GetChildren(); + if (_attachedChildren.Count > 0) + { + var currentPeers = new HashSet(childPeers); + var removedChildren = _attachedChildren + .Where(childNode => !currentPeers.Contains(childNode.Peer)) + .ToArray(); + + foreach (var oldChild in removedChildren) + { + if (ReferenceEquals(oldChild.Parent, this)) + Server.DetachSubtreeRecursive(oldChild); + } + + if (removedChildren.Length > 0) + { + var removedSet = new HashSet(removedChildren); + _attachedChildren = _attachedChildren + .Where(childNode => !removedSet.Contains(childNode)) + .ToList(); + } + } + + if (!Server.HasEventListeners || EventObjectHandler is not { } eventHandler) return; + var reference = Server.GetReference(this); + var childVariant = new DBusVariant(reference.ToDbusStruct()); + eventHandler.EmitChildrenChangedSignal("add", 0, childVariant); + } + + private void OnPeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) + { + if (Server.A11yConnection is null || !Server.HasEventListeners) + return; + + if (EventObjectHandler is not { } eventHandler) + return; + + if (e.Property == AutomationElementIdentifiers.NameProperty) + { + eventHandler.EmitPropertyChangeSignal( + "accessible-name", + new DBusVariant(GetAccessibleName(Peer))); + } + else if (e.Property == AutomationElementIdentifiers.HelpTextProperty) + { + eventHandler.EmitPropertyChangeSignal( + "accessible-description", + new DBusVariant(e.NewValue?.ToString() ?? string.Empty)); + } + else if (e.Property == TogglePatternIdentifiers.ToggleStateProperty) + { + var newState = e.NewValue is ToggleState ts ? ts : ToggleState.Off; + eventHandler.EmitStateChangedSignal( + "checked", newState == ToggleState.On ? 1 : 0, new DBusVariant(0)); + eventHandler.EmitStateChangedSignal( + "indeterminate", newState == ToggleState.Indeterminate ? 1 : 0, new DBusVariant(0)); + } + else if (e.Property == ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty) + { + var newState = e.NewValue is ExpandCollapseState ecs ? ecs : ExpandCollapseState.Collapsed; + eventHandler.EmitStateChangedSignal( + "expanded", newState == ExpandCollapseState.Expanded ? 1 : 0, new DBusVariant(0)); + eventHandler.EmitStateChangedSignal( + "collapsed", newState == ExpandCollapseState.Collapsed ? 1 : 0, new DBusVariant(0)); + } + else if (e.Property == ValuePatternIdentifiers.ValueProperty) + { + eventHandler.EmitPropertyChangeSignal( + "accessible-value", + new DBusVariant(e.NewValue?.ToString() ?? string.Empty)); + } + else if (e.Property == SelectionPatternIdentifiers.SelectionProperty) + { + eventHandler.EmitSelectionChangedSignal(); + } + else if (e.Property == AutomationElementIdentifiers.BoundingRectangleProperty) + { + eventHandler.EmitBoundsChangedSignal(); + } + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiRegistryEventTracker.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiRegistryEventTracker.cs new file mode 100644 index 0000000000..72967d504d --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiRegistryEventTracker.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using Avalonia.Logging; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + /// + /// Monitors the AT-SPI registry to determine if any screen reader is listening for events. + /// + internal sealed class AtSpiRegistryEventTracker : IDisposable + { + private readonly DBusConnection _connection; + private readonly HashSet _registeredEvents = new(StringComparer.Ordinal); + + private OrgA11yAtspiRegistryProxy? _registryProxy; + private IDisposable? _registryRegisteredSubscription; + private IDisposable? _registryDeregisteredSubscription; + private IDisposable? _registryOwnerChangedSubscription; + private string? _registryUniqueName; + + internal AtSpiRegistryEventTracker(DBusConnection connection) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + } + + /// + /// Indicates whether any screen reader is currently listening for object events. + /// Defaults to true (chatty) until registry tracking confirms otherwise. + /// + internal bool HasEventListeners { get; private set; } = true; + + internal async Task InitializeAsync() + { + try + { + _registryProxy ??= new OrgA11yAtspiRegistryProxy( + _connection, BusNameRegistry, new DBusObjectPath(RegistryPath)); + + // Seed from current registrations + var events = await _registryProxy.GetRegisteredEventsAsync(); + _registeredEvents.Clear(); + foreach (var registered in events) + _registeredEvents.Add(registered.EventName); + UpdateHasEventListeners(); + + // Resolve registry unique name and subscribe to signals + var registryOwner = await _connection.GetNameOwnerAsync(BusNameRegistry); + await SubscribeToRegistrySignalsAsync(registryOwner); + + // Watch for registry daemon restarts + _registryOwnerChangedSubscription ??= await _connection.WatchNameOwnerChangedAsync( + (name, oldOwner, newOwner) => + { + if (!string.Equals(name, BusNameRegistry, StringComparison.Ordinal)) + return; + + _ = SubscribeToRegistrySignalsAsync(newOwner); + }, + emitOnCapturedContext: true); + } + catch (Exception e) + { + // Registry event tracking unavailable - remain chatty. + Logger.TryGet(LogEventLevel.Debug, LogArea.FreeDesktopPlatform)? + .Log(this, "AT-SPI registry event tracker unavailable; remaining chatty: {Exception}", e); + HasEventListeners = true; + } + } + + public void Dispose() + { + _registryOwnerChangedSubscription?.Dispose(); + _registryOwnerChangedSubscription = null; + _registryRegisteredSubscription?.Dispose(); + _registryRegisteredSubscription = null; + _registryDeregisteredSubscription?.Dispose(); + _registryDeregisteredSubscription = null; + _registryProxy = null; + _registryUniqueName = null; + _registeredEvents.Clear(); + } + + private async Task SubscribeToRegistrySignalsAsync(string? registryOwner) + { + if (string.Equals(_registryUniqueName, registryOwner, StringComparison.Ordinal)) + return; + + // Dispose old subscriptions + _registryRegisteredSubscription?.Dispose(); + _registryRegisteredSubscription = null; + _registryDeregisteredSubscription?.Dispose(); + _registryDeregisteredSubscription = null; + _registryUniqueName = registryOwner; + + var senderFilter = string.IsNullOrWhiteSpace(registryOwner) ? null : registryOwner; + + _registryProxy ??= new OrgA11yAtspiRegistryProxy( + _connection, BusNameRegistry, new DBusObjectPath(RegistryPath)); + + try + { + _registryRegisteredSubscription = await _registryProxy.WatchEventListenerRegisteredAsync( + OnRegistryEventListenerRegistered, + senderFilter, + emitOnCapturedContext: true); + + _registryDeregisteredSubscription = await _registryProxy.WatchEventListenerDeregisteredAsync( + OnRegistryEventListenerDeregistered, + senderFilter, + emitOnCapturedContext: true); + } + catch (Exception e) + { + Logger.TryGet(LogEventLevel.Debug, LogArea.FreeDesktopPlatform)? + .Log(this, "AT-SPI registry signal subscription failed; remaining chatty: {Exception}", e); + _registryRegisteredSubscription?.Dispose(); + _registryRegisteredSubscription = null; + _registryDeregisteredSubscription?.Dispose(); + _registryDeregisteredSubscription = null; + HasEventListeners = true; + } + } + + private void OnRegistryEventListenerRegistered(string bus, string @event, List properties) + { + _registeredEvents.Add(@event); + UpdateHasEventListeners(); + } + + private void OnRegistryEventListenerDeregistered(string bus, string @event) + { + _registeredEvents.Remove(@event); + UpdateHasEventListeners(); + } + + private void UpdateHasEventListeners() + { + HasEventListeners = _registeredEvents.Any(IsObjectEventClass); + } + + private static bool IsObjectEventClass(string eventName) + { + if (string.IsNullOrWhiteSpace(eventName)) + return false; + + if (eventName == "*") + return true; + + return eventName.StartsWith("object:", StringComparison.OrdinalIgnoreCase) + || eventName.StartsWith("window:", StringComparison.OrdinalIgnoreCase) + || eventName.StartsWith("focus:", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiRole.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiRole.cs new file mode 100644 index 0000000000..2b0e6fc2bc --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiRole.cs @@ -0,0 +1,126 @@ +namespace Avalonia.FreeDesktop.AtSpi; + +/// +/// AT-SPI2 role IDs +/// +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, +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiServer.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiServer.cs new file mode 100644 index 0000000000..16d36eb590 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiServer.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Automation.Peers; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using Avalonia.FreeDesktop.AtSpi.Handlers; +using Avalonia.Logging; +using Avalonia.Threading; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi +{ + /// + /// Manages the AT-SPI D-Bus connection, node registration, and event emission. + /// + internal sealed class AtSpiServer : IAsyncDisposable + { + private readonly Dictionary _nodesByPath = new(StringComparer.Ordinal); + private readonly Dictionary _nodesByPeer = []; + private readonly object _embedSync = new(); + + private int _nextNodeId; + private DBusConnection? _a11yConnection; + private string _uniqueName = string.Empty; + private SynchronizationContext? _syncContext; + private ApplicationAtSpiNode? _appRoot; + private AtSpiCacheHandler? _cacheHandler; + private AtSpiEventObjectHandler? _appRootEventHandler; + private AtSpiRegistryEventTracker? _registryTracker; + private IDisposable? _appRootRegistration; + private IDisposable? _cacheRegistration; + private Task? _embedTask; + private bool _isEmbedded; + + internal DBusConnection? A11yConnection => _a11yConnection; + internal SynchronizationContext? SyncContext => _syncContext; + internal string UniqueName => _uniqueName; + + /// + /// Indicates whether any screen reader is currently listening for object events. + /// Defaults to true (chatty) until registry tracking confirms otherwise. + /// + internal bool HasEventListeners => _registryTracker?.HasEventListeners ?? true; + + /// + /// Starts the AT-SPI server. + /// Must be called on the UI thread. + /// + /// Call order in this method is important because + /// AT's are sensitive and/or broken. + /// + /// + public async Task StartAsync() + { + lock (_embedSync) + { + _embedTask = null; + _isEmbedded = false; + } + + _syncContext = new AvaloniaSynchronizationContext(DispatcherPriority.Normal); + + var address = await GetAccessibilityBusAddressAsync(); + + if (string.IsNullOrWhiteSpace(address)) + throw new InvalidOperationException("Failed to resolve the accessibility bus address."); + + _a11yConnection = await DBusConnection.ConnectAsync(address); + _uniqueName = await _a11yConnection.GetUniqueNameAsync() ?? string.Empty; + + _appRoot = new ApplicationAtSpiNode(null); + + _cacheHandler = new AtSpiCacheHandler(); + await BuildAndRegisterAppRootAsync(); + + await RegisterCachePathAsync(); + + _registryTracker = new AtSpiRegistryEventTracker(_a11yConnection); + _ = InitializeRegistryTrackerAsync(_registryTracker); + } + + /// + /// Adds a window to the AT-SPI tree. + /// + public void AddWindow(AutomationPeer windowPeer) + { + if (_a11yConnection is null || _appRoot is null) + return; + + // Idempotent check + if (TryGetAttachedNode(windowPeer) is RootAtSpiNode) + return; + + if (GetOrCreateNode(windowPeer) is not RootAtSpiNode windowNode) + return; + + windowNode.AppRoot = _appRoot; + if (!AttachNode(windowNode, parent: null)) + return; + + if (!_appRoot.WindowChildren.Contains(windowNode)) + _appRoot.AddWindowChild(windowNode); + + var isEmbedded = false; + lock (_embedSync) + isEmbedded = _isEmbedded; + + // GTK-like root registration behavior: + // embed once, then only emit incremental children-changed for later windows. + if (isEmbedded) + { + EmitWindowChildAdded(windowNode); + } + else + { + // Embed may already be in flight from StartAsync. The embed completion path + // emits children-changed for all currently tracked windows. + _ = EnsureEmbeddedAndAnnounceAsync(); + } + } + + private Task EnsureEmbeddedAndAnnounceAsync() + { + lock (_embedSync) + { + if (_isEmbedded) + return Task.CompletedTask; + + // Ignore repeated embed requests while one is already in flight. + if (_embedTask is { IsCompleted: false }) + return _embedTask; + + _embedTask = EmbedAndAnnounceOnceAsync(); + return _embedTask; + } + } + + private async Task EmbedAndAnnounceOnceAsync() + { + try + { + await EmbedApplicationAsync(); + } + catch (Exception e) + { + // Embed failed - screen reader won't discover us. + // Reset so the next AddWindow retries. + Logger.TryGet(LogEventLevel.Warning, LogArea.FreeDesktopPlatform)? + .Log(this, "AT-SPI embed failed; will retry when windows are added: {Exception}", e); + lock (_embedSync) + _embedTask = null; + return; + } + + lock (_embedSync) + { + _isEmbedded = true; + _embedTask = null; + } + + // Now that the screen reader knows about us, emit children-changed + // for every window that was added before the embed completed. + if (!HasEventListeners || _appRootEventHandler is not { } eventHandler || _appRoot is null) + return; + + var children = _appRoot.WindowChildren.ToArray(); + for (var i = 0; i < children.Length; i++) + { + var childRef = GetReference(children[i]); + var childVariant = new DBusVariant(childRef.ToDbusStruct()); + eventHandler.EmitChildrenChangedSignal("add", i, childVariant); + } + } + + private void EmitWindowChildAdded(RootAtSpiNode windowNode) + { + if (!HasEventListeners || _appRootEventHandler is not { } eventHandler) return; + var childRef = GetReference(windowNode); + var childVariant = new DBusVariant(childRef.ToDbusStruct()); + eventHandler.EmitChildrenChangedSignal( + "add", _appRoot!.WindowChildren.Count - 1, childVariant); + } + + /// + /// Removes a window from the AT-SPI tree. + /// + public void RemoveWindow(AutomationPeer windowPeer) + { + if (_a11yConnection is null || _appRoot is null) + return; + + var windowNode = TryGetAttachedNode(windowPeer) as RootAtSpiNode; + if (windowNode is null) + return; + + // Emit children-changed("remove") on app root before removal (guarded by event listeners) + if (HasEventListeners && _appRootEventHandler is { } eventHandler) + { + var index = _appRoot.WindowChildren.IndexOf(windowNode); + var childRef = GetReference(windowNode); + var childVariant = new DBusVariant(childRef.ToDbusStruct()); + eventHandler.EmitChildrenChangedSignal("remove", index, childVariant); + } + + DetachSubtreeRecursive(windowNode); + } + + public async ValueTask DisposeAsync() + { + lock (_embedSync) + { + _isEmbedded = false; + _embedTask = null; + } + + _registryTracker?.Dispose(); + _registryTracker = null; + + var nodes = _nodesByPath.Values.ToArray(); + + // Dispose all D-Bus registrations before the connection. + foreach (var node in nodes) + { + await node.DisposePathRegistrationAsync(); + } + + _appRootRegistration?.Dispose(); + _appRootRegistration = null; + _cacheRegistration?.Dispose(); + _cacheRegistration = null; + + if (_a11yConnection is not null) + { + await _a11yConnection.DisposeAsync(); + _a11yConnection = null; + } + + foreach (var node in nodes) + ReleaseNode(node); + + _uniqueName = string.Empty; + _cacheHandler = null; + _appRoot = null; + _appRootEventHandler = null; + _nodesByPath.Clear(); + _nodesByPeer.Clear(); + } + + internal AtSpiObjectReference GetReference(AtSpiNode? node) + { + if (node is null || !node.IsAttached || !_nodesByPath.ContainsKey(node.Path)) + 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 void EmitWindowActivationChange(RootAtSpiNode windowNode, bool active) + { + if (_a11yConnection is null || !HasEventListeners) + return; + + if (windowNode.EventObjectHandler is { } eventHandler) + eventHandler.EmitStateChangedSignal("active", active ? 1 : 0, new DBusVariant(0)); + + if (windowNode.EventWindowHandler is not { } windowHandler) + return; + + if (active) + windowHandler.EmitActivateSignal(); + else + windowHandler.EmitDeactivateSignal(); + } + + internal void EmitFocusChange(AtSpiNode? focusedNode) + { + if (_a11yConnection is null || focusedNode is null || !HasEventListeners) + return; + + if (focusedNode.EventObjectHandler is { } eventHandler) + eventHandler.EmitStateChangedSignal("focused", 1, new DBusVariant(0)); + } + + internal string AllocateNodePath() + { + return $"{AppPathPrefix}/{Interlocked.Increment(ref _nextNodeId)}"; + } + + internal AtSpiNode GetOrCreateNode(AutomationPeer peer) + { + if (_nodesByPeer.TryGetValue(peer, out var node)) + return node; + + node = AtSpiNode.Create(peer, this); + _nodesByPeer[peer] = node; + return node; + } + + internal AtSpiNode? TryGetNode(AutomationPeer? peer) + { + if (peer is null) + return null; + + _nodesByPeer.TryGetValue(peer, out var node); + return node; + } + + internal AtSpiNode? TryGetAttachedNode(AutomationPeer? peer) + { + var node = TryGetNode(peer); + return node is { IsAttached: true } && _nodesByPath.ContainsKey(node.Path) ? node : null; + } + + internal bool AttachNode(AtSpiNode node, AtSpiNode? parent) + { + if (_a11yConnection is null) + return false; + + if (parent is not null && !parent.IsAttached) + return false; + + if (node.IsAttached) + { + if (!ReferenceEquals(node.Parent, parent)) + { + node.Parent?.RemoveAttachedChild(node); + node.SetParent(parent); + } + + node.BuildAndRegisterHandlers(_a11yConnection, _syncContext); + _nodesByPath[node.Path] = node; + return true; + } + + node.Attach(parent); + if (!node.IsAttached) + return false; + + _nodesByPath[node.Path] = node; + return true; + } + + internal void DetachSubtreeRecursive(AtSpiNode rootNode) + { + var toRemove = new List(); + CollectSubtree(rootNode, toRemove); + RemoveNodes(toRemove, emitDefunct: true); + } + + private static void CollectSubtree(AtSpiNode node, List result) + { + foreach (var child in node.AttachedChildren.ToArray()) + CollectSubtree(child, result); + + result.Add(node); + } + + private void RemoveNodes(IEnumerable nodes, bool emitDefunct) + { + foreach (var node in nodes) + { + if (!node.IsAttached || !_nodesByPath.ContainsKey(node.Path)) + continue; + + if (emitDefunct && HasEventListeners && node.EventObjectHandler is { } eventHandler) + eventHandler.EmitStateChangedSignal("defunct", 1, new DBusVariant("0")); + + _nodesByPath.Remove(node.Path); + node.Parent?.RemoveAttachedChild(node); + + if (node is RootAtSpiNode rootNode) + _appRoot?.RemoveWindowChild(rootNode); + + ReleaseNode(node); + } + } + + private async Task BuildAndRegisterAppRootAsync() + { + if (_a11yConnection is null || _appRoot is null) + return; + + _appRootRegistration?.Dispose(); + _appRootRegistration = null; + + var accessibleHandler = new ApplicationAccessibleHandler(this, _appRoot); + var applicationHandler = new ApplicationNodeApplicationHandler(); + var eventHandler = new AtSpiEventObjectHandler(this, _appRoot.Path); + _appRootEventHandler = eventHandler; + + var targets = new List { accessibleHandler, applicationHandler, eventHandler }; + _appRootRegistration = await _a11yConnection.RegisterObjects( + (DBusObjectPath)RootPath, + targets, + _syncContext); + } + + private async Task RegisterCachePathAsync() + { + if (_a11yConnection is null || _cacheHandler is null) + return; + + _cacheRegistration?.Dispose(); + _cacheRegistration = null; + + _cacheRegistration = await _a11yConnection.RegisterObjects( + (DBusObjectPath)CachePath, + [_cacheHandler], + _syncContext); + } + + 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 (Exception e) + { + Logger.TryGet(LogEventLevel.Debug, LogArea.FreeDesktopPlatform)? + .Log(this, "Failed to resolve AT-SPI accessibility bus address: {Exception}", e); + 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 static async Task InitializeRegistryTrackerAsync(AtSpiRegistryEventTracker tracker) + { + try + { + await tracker.InitializeAsync(); + } + catch (Exception e) + { + // Registry tracking is best-effort; AT-SPI server remains functional without it. + Logger.TryGet(LogEventLevel.Debug, LogArea.FreeDesktopPlatform)? + .Log(tracker, "AT-SPI registry listener tracking initialization failed: {Exception}", e); + } + } + + private void ReleaseNode(AtSpiNode node) + { + node.Detach(); + _nodesByPeer.Remove(node.Peer); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/AtSpiState.cs b/src/Avalonia.FreeDesktop.AtSpi/AtSpiState.cs new file mode 100644 index 0000000000..33e0ef4d26 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/AtSpiState.cs @@ -0,0 +1,52 @@ +namespace Avalonia.FreeDesktop.AtSpi; + +/// +/// 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, +} \ No newline at end of file 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..ddedec88bf --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Avalonia.FreeDesktop.AtSpi.csproj @@ -0,0 +1,58 @@ + + + + $(AvsCurrentTargetFramework);$(AvsLegacyTargetFrameworks) + $(DefineConstants);AVDBUS_INTERNAL + true + false + true + ../../external/Avalonia.DBus/src/Avalonia.DBus + + + + + + + + + + + Shared/StringUtils.cs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Accessible.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Accessible.xml new file mode 100644 index 0000000000..7c78607ec4 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Accessible.xml @@ -0,0 +1,876 @@ + + + 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..cef9f62c2f --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Action.xml @@ -0,0 +1,144 @@ + + + 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..3c66945069 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Cache.xml @@ -0,0 +1,114 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Collection.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Collection.xml new file mode 100644 index 0000000000..f8d9aa36d6 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Collection.xml @@ -0,0 +1,202 @@ + + + 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..62ce2ce8e4 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Component.xml @@ -0,0 +1,344 @@ + + + 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..83826a9867 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/DeviceEventController.xml @@ -0,0 +1,142 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/EditableText.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/EditableText.xml new file mode 100644 index 0000000000..2d61959864 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/EditableText.xml @@ -0,0 +1,134 @@ + + + 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..e16294248d --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Event.xml @@ -0,0 +1,742 @@ + + + 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..9d80e17bd7 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Image.xml @@ -0,0 +1,86 @@ + + + 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..83d5b7bff3 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Registry.xml @@ -0,0 +1,130 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Selection.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Selection.xml new file mode 100644 index 0000000000..e4d5836651 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Selection.xml @@ -0,0 +1,148 @@ + + + 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..87807118e0 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Socket.xml @@ -0,0 +1,98 @@ + + + Types.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Status.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Status.xml new file mode 100644 index 0000000000..0dfff8cd2b --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Status.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Text.xml b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Text.xml new file mode 100644 index 0000000000..8f17094cf9 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/DBusXml/Text.xml @@ -0,0 +1,688 @@ + + + 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..ee79116f4b --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiAccessibleHandler.cs @@ -0,0 +1,152 @@ +using System; +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.Handlers +{ + /// + /// Implements the AT-SPI Accessible interface for an AutomationPeer-backed node. + /// + internal sealed class AtSpiAccessibleHandler(AtSpiServer server, AtSpiNode node) : IOrgA11yAtspiAccessible + { + public uint Version => AccessibleVersion; + + public string Name => AtSpiNode.GetAccessibleName(node.Peer); + + public string Description => node.Peer.GetHelpText(); + + public AtSpiObjectReference Parent + { + get + { + // For window nodes, return the ApplicationAtSpiNode as parent + if (node is RootAtSpiNode { AppRoot: { } appRoot }) + return new AtSpiObjectReference( + server.UniqueName, new DBusObjectPath(appRoot.Path)); + + return server.GetReference(node.Parent); + } + } + + public int ChildCount => node.EnsureChildren().Count; + + public string Locale => ResolveLocale(); + + public string AccessibleId => node.Peer.GetAutomationId() ?? string.Empty; + + public string HelpText => node.Peer.GetHelpText(); + + public ValueTask GetChildAtIndexAsync(int index) + { + var children = node.EnsureChildren(); + if (index < 0 || index >= children.Count) + return ValueTask.FromResult(server.GetNullReference()); + + return ValueTask.FromResult(server.GetReference(children[index])); + } + + public ValueTask> GetChildrenAsync() + { + var children = node.EnsureChildren(); + var refs = new List(children.Count); + foreach (var child in children) + { + refs.Add(server.GetReference(child)); + } + + return ValueTask.FromResult(refs); + } + + public ValueTask GetIndexInParentAsync() + { + var parent = node.Parent; + if (parent is null) + return ValueTask.FromResult(-1); + + var siblings = parent.EnsureChildren(); + for (var i = 0; i < siblings.Count; i++) + { + if (ReferenceEquals(siblings[i], node)) + return ValueTask.FromResult(i); + } + + return ValueTask.FromResult(-1); + } + + public ValueTask> GetRelationSetAsync() + { + var relations = new List(); + + var labeledBy = node.Peer.GetLabeledBy(); + if (labeledBy is not null) + { + var labelNode = server.TryGetAttachedNode(labeledBy); + if (labelNode is not null) + { + // Relation type 2 = LABELLED_BY + relations.Add(new AtSpiRelationEntry(2, [server.GetReference(labelNode)])); + } + } + + return ValueTask.FromResult(relations); + } + + public ValueTask GetRoleAsync() + { + var role = AtSpiNode.ToAtSpiRole(node.Peer.GetAutomationControlType(), node.Peer); + return ValueTask.FromResult((uint)role); + } + + public ValueTask GetRoleNameAsync() + { + var role = AtSpiNode.ToAtSpiRole(node.Peer.GetAutomationControlType(), node.Peer); + return ValueTask.FromResult(AtSpiNode.ToAtSpiRoleName(role)); + } + + public ValueTask GetLocalizedRoleNameAsync() + { + var role = AtSpiNode.ToAtSpiRole(node.Peer.GetAutomationControlType(), node.Peer); + return ValueTask.FromResult(AtSpiNode.ToAtSpiRoleName(role)); + } + + public ValueTask> GetStateAsync() + { + return ValueTask.FromResult(node.ComputeStates()); + } + + public ValueTask GetAttributesAsync() + { + var attrs = new AtSpiAttributeSet { ["toolkit"] = "Avalonia" }; + + var name = node.Peer.GetName(); + if (!string.IsNullOrEmpty(name)) + attrs["explicit-name"] = "true"; + + var acceleratorKey = node.Peer.GetAcceleratorKey(); + if (!string.IsNullOrEmpty(acceleratorKey)) + attrs["accelerator-key"] = acceleratorKey; + + var accessKey = node.Peer.GetAccessKey(); + if (!string.IsNullOrEmpty(accessKey)) + attrs["access-key"] = accessKey; + + return ValueTask.FromResult(attrs); + } + + public ValueTask GetApplicationAsync() + { + return ValueTask.FromResult(server.GetRootReference()); + } + + public ValueTask> GetInterfacesAsync() + { + var interfaces = 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..7ccfbfccb5 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiActionHandler.cs @@ -0,0 +1,172 @@ +using System.Collections.Generic; +using System.Linq; +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 +{ + /// + /// Implements the AT-SPI Action interface (invoke, toggle, expand/collapse, scroll). + /// + 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) + { + if (index >= 0 && index < _actions.Count) + return ValueTask.FromResult(_actions[index].KeyBinding); + return ValueTask.FromResult(string.Empty); + } + + public ValueTask> GetActionsAsync() + { + var result = new List(_actions.Count); + result.AddRange(_actions.Select(entry => new AtSpiAction(entry.LocalizedName, entry.Description, entry.KeyBinding))); + return ValueTask.FromResult(result); + } + + public ValueTask DoActionAsync(int index) + { + if (index < 0 || index >= _actions.Count) + return ValueTask.FromResult(false); + + var action = _actions[index]; + 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 or collapse": + if (_node.Peer.GetProvider() is { } expandCollapseAction) + { + if (expandCollapseAction.ExpandCollapseState == ExpandCollapseState.Collapsed) + expandCollapseAction.Expand(); + else + expandCollapseAction.Collapse(); + } + break; + case "scroll up": + _node.Peer.GetProvider()?.Scroll( + ScrollAmount.NoAmount, ScrollAmount.SmallDecrement); + break; + case "scroll down": + _node.Peer.GetProvider()?.Scroll( + ScrollAmount.NoAmount, ScrollAmount.SmallIncrement); + break; + case "scroll left": + _node.Peer.GetProvider()?.Scroll( + ScrollAmount.SmallDecrement, ScrollAmount.NoAmount); + break; + case "scroll right": + _node.Peer.GetProvider()?.Scroll( + ScrollAmount.SmallIncrement, ScrollAmount.NoAmount); + break; + // Provisional: activate selectable items (TabItem, ListBoxItem) via Action. + case "select": + _node.Peer.GetProvider()?.Select(); + break; + } + } + // TODO: Proper mapping of ActionList keybindings to AutomationPeers. + private List BuildActionList() + { + var actions = new List(); + + if (_node.Peer.GetProvider() is not null) + { + var acceleratorKey = _node.Peer.GetAcceleratorKey() ?? string.Empty; + actions.Add(new ActionEntry("click", "Click", "Performs the default action", acceleratorKey)); + } + + if (_node.Peer.GetProvider() is not null) + actions.Add(new ActionEntry("toggle", "Toggle", "Toggles the control state", string.Empty)); + + if (_node.Peer.GetProvider() is not null) + actions.Add(new ActionEntry("expand or collapse", "Expand or Collapse", "Expands or collapses the control", string.Empty)); + + if (_node.Peer.GetProvider() is { } scroll) + { + if (scroll.VerticallyScrollable) + { + actions.Add(new ActionEntry("scroll up", "Scroll Up", "Scrolls the view up", string.Empty)); + actions.Add(new ActionEntry("scroll down", "Scroll Down", "Scrolls the view down", string.Empty)); + } + + if (scroll.HorizontallyScrollable) + { + actions.Add(new ActionEntry("scroll left", "Scroll Left", "Scrolls the view left", string.Empty)); + actions.Add(new ActionEntry("scroll right", "Scroll Right", "Scrolls the view right", string.Empty)); + } + } + + if (_node.Peer.GetProvider() is not null + && _node.Peer.GetProvider() is null) + { + actions.Add(new ActionEntry("select", "Select", "Selects this item", string.Empty)); + } + + return actions; + } + + /// + /// Describes a single AT-SPI action exposed by a node. + /// + private readonly struct ActionEntry( + string actionName, + string localizedName, + string description, + string keyBinding) + { + public string ActionName { get; } = actionName; + public string LocalizedName { get; } = localizedName; + public string Description { get; } = description; + public string KeyBinding { get; } = keyBinding; + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiCollectionHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiCollectionHandler.cs new file mode 100644 index 0000000000..41b48b3a21 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiCollectionHandler.cs @@ -0,0 +1,272 @@ +using System; +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.Handlers +{ + /// + /// Implements the AT-SPI Collection interface for match-based node queries. + /// + internal sealed class AtSpiCollectionHandler(AtSpiServer server, AtSpiNode node) : IOrgA11yAtspiCollection + { + private enum MatchType + { + Invalid = 0, + All = 1, + Any = 2, + None = 3, + Empty = 4, + } + + public uint Version => CollectionVersion; + + public ValueTask> GetMatchesAsync( + AtSpiMatchRule rule, uint sortby, int count, bool traverse) + { + var results = new List(); + CollectMatches(node, rule, count, traverse, results, skipSelf: true); + return ValueTask.FromResult(results); + } + + public ValueTask> GetMatchesToAsync( + DBusObjectPath currentObject, AtSpiMatchRule rule, uint sortby, uint tree, + bool limitScope, int count, bool traverse) + { + // GetMatchesTo: find matches after currentObject in tree order + var results = new List(); + var found = false; + CollectMatchesOrdered(node, rule, count, traverse, results, + currentObject.ToString(), ref found, after: true); + return ValueTask.FromResult(results); + } + + public ValueTask> GetMatchesFromAsync( + DBusObjectPath currentObject, AtSpiMatchRule rule, uint sortby, uint tree, + int count, bool traverse) + { + // GetMatchesFrom: find matches before currentObject in tree order + var results = new List(); + var found = false; + CollectMatchesOrdered(node, rule, count, traverse, results, + currentObject.ToString(), ref found, after: false); + return ValueTask.FromResult(results); + } + + public ValueTask GetActiveDescendantAsync() + { + // Not implemented in most toolkits + return ValueTask.FromResult(server.GetNullReference()); + } + + private void CollectMatches( + AtSpiNode parent, AtSpiMatchRule rule, int count, bool traverse, + List results, bool skipSelf) + { + if (count > 0 && results.Count >= count) + return; + + if (!skipSelf && MatchesRule(parent, rule)) + { + results.Add(server.GetReference(parent)); + if (count > 0 && results.Count >= count) + return; + } + + var children = parent.EnsureChildren(); + foreach (var childNode in children) + { + if (MatchesRule(childNode, rule)) + { + results.Add(server.GetReference(childNode)); + if (count > 0 && results.Count >= count) + return; + } + + if (traverse) + CollectMatches(childNode, rule, count, traverse, results, skipSelf: true); + } + } + + private void CollectMatchesOrdered( + AtSpiNode parent, AtSpiMatchRule rule, int count, bool traverse, + List results, string targetPath, ref bool pastTarget, + bool after) + { + if (count > 0 && results.Count >= count) + return; + + var children = parent.EnsureChildren(); + foreach (var childNode in children) + { + if (count > 0 && results.Count >= count) + return; + + if (string.Equals(childNode.Path, targetPath, StringComparison.Ordinal)) + { + pastTarget = true; + if (traverse) + CollectMatchesOrdered(childNode, rule, count, traverse, results, + targetPath, ref pastTarget, after); + continue; + } + + var shouldInclude = after ? pastTarget : !pastTarget; + if (shouldInclude && MatchesRule(childNode, rule)) + { + results.Add(server.GetReference(childNode)); + if (count > 0 && results.Count >= count) + return; + } + + if (traverse) + CollectMatchesOrdered(childNode, rule, count, traverse, results, + targetPath, ref pastTarget, after); + } + } + + private static bool MatchesRule(AtSpiNode node, AtSpiMatchRule rule) + { + var match = MatchesStates(node, rule.States, (MatchType)rule.StateMatchType) + && MatchesRoles(node, rule.Roles, (MatchType)rule.RoleMatchType) + && MatchesInterfaces(node, rule.Interfaces, (MatchType)rule.InterfaceMatchType) + && MatchesAttributes(node, rule.Attributes, (MatchType)rule.AttributeMatchType); + + return rule.Invert ? !match : match; + } + + private static bool MatchesStates(AtSpiNode node, List ruleStates, MatchType matchType) + { + if (matchType is MatchType.Invalid or MatchType.Empty) + return matchType != MatchType.Empty || IsEmptyBitSet(ruleStates); + + if (IsEmptyBitSet(ruleStates)) + return true; + + var nodeStates = node.ComputeStates(); + var nodeLow = nodeStates.Count > 0 ? nodeStates[0] : 0u; + var nodeHigh = nodeStates.Count > 1 ? nodeStates[1] : 0u; + var ruleLow = ruleStates.Count > 0 ? (uint)ruleStates[0] : 0u; + var ruleHigh = ruleStates.Count > 1 ? (uint)ruleStates[1] : 0u; + + return matchType switch + { + MatchType.All => (nodeLow & ruleLow) == ruleLow && (nodeHigh & ruleHigh) == ruleHigh, + MatchType.Any => (nodeLow & ruleLow) != 0 || (nodeHigh & ruleHigh) != 0, + MatchType.None => (nodeLow & ruleLow) == 0 && (nodeHigh & ruleHigh) == 0, + _ => true, + }; + } + + private static bool MatchesRoles(AtSpiNode node, List ruleRoles, MatchType matchType) + { + if (matchType is MatchType.Invalid or MatchType.Empty) + return matchType != MatchType.Empty || IsEmptyBitSet(ruleRoles); + + if (IsEmptyBitSet(ruleRoles)) + return true; + + var role = (uint)AtSpiNode.ToAtSpiRole(node.Peer.GetAutomationControlType(), node.Peer); + var bucket = (int)(role / 32); + var bit = (int)(role % 32); + var isSet = bucket < ruleRoles.Count && ((uint)ruleRoles[bucket] & (1u << bit)) != 0; + + return matchType switch + { + MatchType.All or MatchType.Any => isSet, + MatchType.None => !isSet, + _ => true, + }; + } + + private static bool MatchesInterfaces(AtSpiNode node, List ruleInterfaces, MatchType matchType) + { + if (matchType is MatchType.Invalid or MatchType.Empty) + return matchType != MatchType.Empty || ruleInterfaces.Count == 0; + + if (ruleInterfaces.Count == 0) + return true; + + var nodeInterfaces = node.GetSupportedInterfaces(); + + return matchType switch + { + MatchType.All => AllInterfacesPresent(nodeInterfaces, ruleInterfaces), + MatchType.Any => AnyInterfacePresent(nodeInterfaces, ruleInterfaces), + MatchType.None => !AnyInterfacePresent(nodeInterfaces, ruleInterfaces), + _ => true, + }; + } + + private static bool AllInterfacesPresent(HashSet nodeInterfaces, List required) + { + return required.All(iface => nodeInterfaces.Contains(ResolveInterfaceName(iface))); + } + + private static bool AnyInterfacePresent(HashSet nodeInterfaces, List required) + { + return required.Any(iface => nodeInterfaces.Contains(ResolveInterfaceName(iface))); + } + + private static string ResolveInterfaceName(string name) + { + // ATs may pass short names like "Action" or full names like "org.a11y.atspi.Action" + return name.Contains('.') ? name : $"org.a11y.atspi.{name}"; + } + + private static bool MatchesAttributes(AtSpiNode node, Dictionary? ruleAttrs, MatchType matchType) + { + if (matchType is MatchType.Invalid or MatchType.Empty) + return matchType != MatchType.Empty || (ruleAttrs == null || ruleAttrs.Count == 0); + + if (ruleAttrs == null || ruleAttrs.Count == 0) + return true; + + // Build node attributes (same as AccessibleHandler.GetAttributesAsync) + var nodeAttrs = new Dictionary(StringComparer.Ordinal) { ["toolkit"] = "Avalonia" }; + var name = node.Peer.GetName(); + if (!string.IsNullOrEmpty(name)) + nodeAttrs["explicit-name"] = "true"; + + return matchType switch + { + MatchType.All => AllAttributesMatch(nodeAttrs, ruleAttrs), + MatchType.Any => AnyAttributeMatches(nodeAttrs, ruleAttrs), + MatchType.None => !AnyAttributeMatches(nodeAttrs, ruleAttrs), + _ => true, + }; + } + + private static bool AllAttributesMatch(Dictionary nodeAttrs, Dictionary required) + { + foreach (var kv in required) + { + if (!nodeAttrs.TryGetValue(kv.Key, out var value) || + !string.Equals(value, kv.Value, StringComparison.Ordinal)) + return false; + } + return true; + } + + private static bool AnyAttributeMatches(Dictionary nodeAttrs, Dictionary required) + { + foreach (var kv in required) + { + if (nodeAttrs.TryGetValue(kv.Key, out var value) && + string.Equals(value, kv.Value, StringComparison.Ordinal)) + return true; + } + return false; + } + + private static bool IsEmptyBitSet(List? values) + { + if (values == null || values.Count == 0) + return true; + return values.All(v => v == 0); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiComponentHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiComponentHandler.cs new file mode 100644 index 0000000000..52fe487643 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiComponentHandler.cs @@ -0,0 +1,133 @@ +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Automation.Provider; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using Avalonia.Platform; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + /// + /// Implements the AT-SPI Component interface (geometry and focus). + /// + internal sealed class AtSpiComponentHandler(AtSpiServer server, AtSpiNode node) : IOrgA11yAtspiComponent + { + + public uint Version => ComponentVersion; + + public ValueTask ContainsAsync(int x, int y, uint coordType) + { + var rect = AtSpiCoordinateHelper.GetScreenExtents(node); + var point = TranslateToScreen(x, y, coordType); + var contains = rect.ContainsExclusive(new Point(point.x, point.y)); + return ValueTask.FromResult(contains); + } + + public ValueTask GetAccessibleAtPointAsync(int x, int y, uint coordType) + { + var rect = AtSpiCoordinateHelper.GetScreenExtents(node); + var point = TranslateToScreen(x, y, coordType); + if (rect.ContainsExclusive(new Point(point.x, point.y))) + { + return ValueTask.FromResult(server.GetReference(node)); + } + + return ValueTask.FromResult(server.GetNullReference()); + } + + public ValueTask GetExtentsAsync(uint coordType) + { + var rect = AtSpiCoordinateHelper.GetScreenExtents(node); + var translated = AtSpiCoordinateHelper.TranslateRect(node, 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 = AtSpiCoordinateHelper.GetScreenExtents(node); + var translated = AtSpiCoordinateHelper.TranslateRect(node, rect, coordType); + return ValueTask.FromResult(((int)translated.X, (int)translated.Y)); + } + + public ValueTask<(int Width, int Height)> GetSizeAsync() + { + var rect = AtSpiCoordinateHelper.GetScreenExtents(node); + return ValueTask.FromResult(((int)rect.Width, (int)rect.Height)); + } + + public ValueTask GetLayerAsync() + { + var controlType = node.Peer.GetAutomationControlType(); + var layer = controlType == Automation.Peers.AutomationControlType.Window + ? WindowLayer + : WidgetLayer; + return ValueTask.FromResult(layer); + } + + public ValueTask GetMDIZOrderAsync() => ValueTask.FromResult((short)-1); + + public ValueTask GrabFocusAsync() + { + 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) + { + // Only support moving (not resizing) for now + return SetPositionAsync(x, y, coordType); + } + + public ValueTask SetPositionAsync(int x, int y, uint coordType) + { + if (node.Peer.GetProvider() is not { PlatformImpl: IWindowImpl windowImpl }) + return ValueTask.FromResult(false); + + var screenPos = TranslateToScreen(x, y, coordType); + windowImpl.Move(new PixelPoint(screenPos.x, screenPos.y)); + return ValueTask.FromResult(true); + } + + 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 (int x, int y) TranslateToScreen(int x, int y, uint coordType) + { + var ct = (AtSpiCoordType)coordType; + + switch (ct) + { + case AtSpiCoordType.Screen: + return (x, y); + case AtSpiCoordType.Window: + { + var windowRect = AtSpiCoordinateHelper.GetWindowRect(node); + return (x + (int)windowRect.X, y + (int)windowRect.Y); + } + case AtSpiCoordType.Parent: + { + var parentRect = AtSpiCoordinateHelper.GetParentScreenRect(node); + return (x + (int)parentRect.X, y + (int)parentRect.Y); + } + default: + return (x, y); + } + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiCoordinateHelper.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiCoordinateHelper.cs new file mode 100644 index 0000000000..8cfac07e67 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiCoordinateHelper.cs @@ -0,0 +1,80 @@ +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + /// + /// Coordinate translation utilities between screen, window, and parent frames. + /// + internal static class AtSpiCoordinateHelper + { + public static Rect GetScreenExtents(AtSpiNode node) + { + var bounds = node.Peer.GetBoundingRectangle(); + if (node is RootAtSpiNode rootNode) + return rootNode.ToScreen(bounds); + + var root = node.Peer.GetVisualRoot(); + + if (root is null) + return bounds; + + if (node.Server.TryGetAttachedNode(root) is RootAtSpiNode rootNode2) + return rootNode2.ToScreen(bounds); + + return bounds; + } + + public static Rect TranslateRect(AtSpiNode node, Rect screenRect, uint coordType) + { + var ct = (AtSpiCoordType)coordType; + + switch (ct) + { + case AtSpiCoordType.Screen: + return screenRect; + case AtSpiCoordType.Window: + { + var windowRect = GetWindowRect(node); + return new Rect( + screenRect.X - windowRect.X, + screenRect.Y - windowRect.Y, + screenRect.Width, + screenRect.Height); + } + case AtSpiCoordType.Parent: + { + var parentRect = GetParentScreenRect(node); + return new Rect( + screenRect.X - parentRect.X, + screenRect.Y - parentRect.Y, + screenRect.Width, + screenRect.Height); + } + default: + return screenRect; + } + } + + public static Rect GetWindowRect(AtSpiNode node) + { + var root = node.Peer.GetVisualRoot(); + if (root is null) return default; + + if (node.Server.TryGetAttachedNode(root) is RootAtSpiNode rootNode) + return rootNode.ToScreen(root.GetBoundingRectangle()); + + return default; + } + + public static Rect GetParentScreenRect(AtSpiNode node) + { + var parent = node.Peer.GetParent(); + if (parent is null) + return default; + var bounds = parent.GetBoundingRectangle(); + var root = parent.GetVisualRoot(); + if (root is null) + return bounds; + var rootNode = node.Server.TryGetAttachedNode(root) as RootAtSpiNode; + return rootNode?.ToScreen(bounds) ?? bounds; + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEditableTextHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEditableTextHandler.cs new file mode 100644 index 0000000000..e5f1c27992 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEditableTextHandler.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Automation.Provider; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + /// + /// Implements the AT-SPI EditableText interface for writable text content. + /// + internal sealed class AtSpiEditableTextHandler(AtSpiNode node) : IOrgA11yAtspiEditableText + { + public uint Version => EditableTextVersion; + + public ValueTask SetTextContentsAsync(string newContents) + { + if (node.Peer.GetProvider() is not { IsReadOnly: false } provider) + return ValueTask.FromResult(false); + + provider.SetValue(newContents); + return ValueTask.FromResult(true); + } + + public ValueTask InsertTextAsync(int position, string text, int length) + { + if (node.Peer.GetProvider() is not { IsReadOnly: false } provider) + return ValueTask.FromResult(false); + + var current = provider.Value ?? string.Empty; + position = Math.Max(0, Math.Min(position, current.Length)); + var toInsert = length >= 0 && length < text.Length ? text.Substring(0, length) : text; + var newValue = current.Insert(position, toInsert); + provider.SetValue(newValue); + return ValueTask.FromResult(true); + } + + public ValueTask CopyTextAsync(int startPos, int endPos) + { + // Clipboard operations not supported via IValueProvider + return ValueTask.CompletedTask; + } + + public ValueTask CutTextAsync(int startPos, int endPos) + { + // Clipboard operations not supported via IValueProvider + return ValueTask.FromResult(false); + } + + public ValueTask DeleteTextAsync(int startPos, int endPos) + { + if (node.Peer.GetProvider() is not { IsReadOnly: false } provider) + return ValueTask.FromResult(false); + + var current = provider.Value ?? string.Empty; + startPos = Math.Max(0, Math.Min(startPos, current.Length)); + endPos = Math.Max(startPos, Math.Min(endPos, current.Length)); + + if (startPos >= endPos) + return ValueTask.FromResult(false); + var newValue = current.Remove(startPos, endPos - startPos); + provider.SetValue(newValue); + return ValueTask.FromResult(true); + } + + public ValueTask PasteTextAsync(int position) + { + // Clipboard operations not supported via IValueProvider + return ValueTask.FromResult(false); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEventObjectHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEventObjectHandler.cs new file mode 100644 index 0000000000..536575ec5b --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEventObjectHandler.cs @@ -0,0 +1,64 @@ +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 +{ + /// + /// Emits AT-SPI Event.Object signals (children-changed, state-changed, property-change). + /// + internal sealed class AtSpiEventObjectHandler(AtSpiServer server, string path) : IOrgA11yAtspiEventObject + { + 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()); + } + + public void EmitSelectionChangedSignal() + { + EmitSignal("SelectionChanged", string.Empty, 0, 0, new DBusVariant(0), EmptyProperties()); + } + + public void EmitBoundsChangedSignal() + { + EmitSignal("BoundsChanged", string.Empty, 0, 0, new DBusVariant(0), EmptyProperties()); + } + + private void EmitSignal(string member, params object[] body) + { + if (!server.HasEventListeners) + return; + + 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/AtSpiEventWindowHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEventWindowHandler.cs new file mode 100644 index 0000000000..a03edcd86c --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiEventWindowHandler.cs @@ -0,0 +1,47 @@ +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 +{ + /// + /// Emits AT-SPI Event.Window signals (activate, deactivate). + /// + internal sealed class AtSpiEventWindowHandler(AtSpiServer server, string path) : IOrgA11yAtspiEventWindow + { + public void EmitActivateSignal() + { + EmitSignal("Activate", string.Empty, 0, 0, new DBusVariant("0"), EmptyProperties()); + } + + public void EmitDeactivateSignal() + { + EmitSignal("Deactivate", string.Empty, 0, 0, new DBusVariant("0"), EmptyProperties()); + } + + private void EmitSignal(string member, params object[] body) + { + if (!server.HasEventListeners) + return; + + var connection = server.A11yConnection; + if (connection is null) + return; + + var message = DBusMessage.CreateSignal( + (DBusObjectPath)path, + IfaceEventWindow, + member, + body); + + _ = connection.SendMessageAsync(message); + } + + private static Dictionary EmptyProperties() + { + return new Dictionary(StringComparer.Ordinal); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiImageHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiImageHandler.cs new file mode 100644 index 0000000000..c0cf9dbd4f --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiImageHandler.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + /// + /// Implements the AT-SPI Image interface for image controls. + /// + internal sealed class AtSpiImageHandler : IOrgA11yAtspiImage + { + private readonly AtSpiNode _node; + + public AtSpiImageHandler(AtSpiServer server, AtSpiNode node) + { + _ = server; + _node = node; + } + + public uint Version => ImageVersion; + + public string ImageDescription => _node.Peer.GetHelpText(); + + public string ImageLocale => ResolveLocale(); + + public ValueTask GetImageExtentsAsync(uint coordType) + { + var rect = AtSpiCoordinateHelper.GetScreenExtents(_node); + var translated = AtSpiCoordinateHelper.TranslateRect(_node, 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)> GetImagePositionAsync(uint coordType) + { + var rect = AtSpiCoordinateHelper.GetScreenExtents(_node); + var translated = AtSpiCoordinateHelper.TranslateRect(_node, rect, coordType); + return ValueTask.FromResult(((int)translated.X, (int)translated.Y)); + } + + public ValueTask<(int Width, int Height)> GetImageSizeAsync() + { + var rect = AtSpiCoordinateHelper.GetScreenExtents(_node); + return ValueTask.FromResult(((int)rect.Width, (int)rect.Height)); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiSelectionHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiSelectionHandler.cs new file mode 100644 index 0000000000..7d38d051d4 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiSelectionHandler.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Automation.Provider; +using Avalonia.DBus; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +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; + + public int NSelectedChildren + { + get + { + var provider = node.Peer.GetProvider(); + return provider?.GetSelection().Count ?? 0; + } + } + + public ValueTask GetSelectedChildAsync(int selectedChildIndex) + { + node.EnsureChildren(); + + var provider = node.Peer.GetProvider(); + if (provider is null) + return ValueTask.FromResult(server.GetNullReference()); + + var selection = provider.GetSelection(); + if (selectedChildIndex < 0 || selectedChildIndex >= selection.Count) + return ValueTask.FromResult(server.GetNullReference()); + + var selectedPeer = selection[selectedChildIndex]; + var childNode = server.TryGetAttachedNode(selectedPeer); + if (childNode is null || !ReferenceEquals(childNode.Parent, node)) + return ValueTask.FromResult(server.GetNullReference()); + + return ValueTask.FromResult(server.GetReference(childNode)); + } + + public ValueTask SelectChildAsync(int childIndex) + { + var children = node.Peer.GetChildren(); + if (childIndex < 0 || childIndex >= children.Count) + return ValueTask.FromResult(false); + + var childPeer = children[childIndex]; + if (childPeer.GetProvider() is not { } selectionItem) + return ValueTask.FromResult(false); + + selectionItem.Select(); + return ValueTask.FromResult(true); + } + + public ValueTask DeselectSelectedChildAsync(int selectedChildIndex) + { + var provider = node.Peer.GetProvider(); + if (provider is null) + return ValueTask.FromResult(false); + + var selection = provider.GetSelection(); + if (selectedChildIndex < 0 || selectedChildIndex >= selection.Count) + return ValueTask.FromResult(false); + + var selectedPeer = selection[selectedChildIndex]; + if (selectedPeer.GetProvider() is not { } selectionItem) + return ValueTask.FromResult(false); + + selectionItem.RemoveFromSelection(); + return ValueTask.FromResult(true); + } + + public ValueTask IsChildSelectedAsync(int childIndex) + { + var children = node.Peer.GetChildren(); + if (childIndex < 0 || childIndex >= children.Count) + return ValueTask.FromResult(false); + + var childPeer = children[childIndex]; + if (childPeer.GetProvider() is not { } selectionItem) + return ValueTask.FromResult(false); + + return ValueTask.FromResult(selectionItem.IsSelected); + } + + public ValueTask SelectAllAsync() + { + var provider = node.Peer.GetProvider(); + 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(); + } + + return ValueTask.FromResult(true); + } + + public ValueTask ClearSelectionAsync() + { + var provider = node.Peer.GetProvider(); + if (provider is null) + return ValueTask.FromResult(false); + + var selection = provider.GetSelection(); + foreach (var selectedPeer in selection) + { + if (selectedPeer.GetProvider() is { } selectionItem) + selectionItem.RemoveFromSelection(); + } + + return ValueTask.FromResult(true); + } + + public ValueTask DeselectChildAsync(int childIndex) + { + var children = node.Peer.GetChildren(); + if (childIndex < 0 || childIndex >= children.Count) + return ValueTask.FromResult(false); + + var childPeer = children[childIndex]; + if (childPeer.GetProvider() is not { } selectionItem) + return ValueTask.FromResult(false); + + selectionItem.RemoveFromSelection(); + return ValueTask.FromResult(true); + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiTextHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiTextHandler.cs new file mode 100644 index 0000000000..daec60666a --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiTextHandler.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Automation.Provider; +using Avalonia.Controls.Utils; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + /// + /// Implements the AT-SPI Text interface for read-only text content. + /// + internal sealed class AtSpiTextHandler(AtSpiNode node) : IOrgA11yAtspiText + { + private enum TextGranularity : uint + { + Char = 0, + Word = 1, + Sentence = 2, + Line = 3, + Paragraph = 4, + } + + private enum TextBoundaryType : uint + { + Char = 0, + WordStart = 1, + WordEnd = 2, + SentenceStart = 3, + SentenceEnd = 4, + LineStart = 5, + LineEnd = 6, + } + + public uint Version => TextVersion; + + public int CharacterCount => GetText().Length; + + public int CaretOffset => 0; + + public ValueTask<(string Text, int StartOffset, int EndOffset)> GetStringAtOffsetAsync( + int offset, uint granularity) + { + var text = GetText(); + if (text.Length == 0) + return ValueTask.FromResult((string.Empty, 0, 0)); + + offset = Math.Max(0, Math.Min(offset, text.Length - 1)); + + var g = (TextGranularity)granularity; + + // For CHAR granularity, return single character + if (g == TextGranularity.Char) + { + var ch = text.Substring(offset, 1); + return ValueTask.FromResult((ch, offset, offset + 1)); + } + + // For WORD granularity, find word boundaries + if (g == TextGranularity.Word) + { + var start = StringUtils.PreviousWord(text, offset + 1); + if (start >= text.Length || !StringUtils.IsStartOfWord(text, start)) + return ValueTask.FromResult((string.Empty, 0, 0)); + + var end = Math.Min(StringUtils.NextWord(text, start), text.Length); + if (end <= start) + return ValueTask.FromResult((string.Empty, 0, 0)); + + return ValueTask.FromResult((text.Substring(start, end - start), start, end)); + } + + // For SENTENCE, LINE, PARAGRAPH - return full text + return ValueTask.FromResult((text, 0, text.Length)); + } + + public ValueTask GetTextAsync(int startOffset, int endOffset) + { + var text = GetText(); + if (text.Length == 0) + return ValueTask.FromResult(string.Empty); + + startOffset = Math.Max(0, startOffset); + if (endOffset < 0 || endOffset > text.Length) + endOffset = text.Length; + + if (startOffset >= endOffset) + return ValueTask.FromResult(string.Empty); + + return ValueTask.FromResult(text.Substring(startOffset, endOffset - startOffset)); + } + + public ValueTask SetCaretOffsetAsync(int offset) + { + return ValueTask.FromResult(false); + } + + public ValueTask<(string Text, int StartOffset, int EndOffset)> GetTextBeforeOffsetAsync( + int offset, uint type) + { + // TODO: This method is a bit sketchy. Might need to wired up to + // our text handling logic in core. + + var text = GetText(); + if (offset <= 0 || text.Length == 0) + return ValueTask.FromResult((string.Empty, 0, 0)); + + offset = Math.Min(offset, text.Length); + + var bt = (TextBoundaryType)type; + + // CHAR boundary + if (bt == TextBoundaryType.Char) + { + var charOffset = offset - 1; + return ValueTask.FromResult((text.Substring(charOffset, 1), charOffset, charOffset + 1)); + } + + // WORD_START or WORD_END boundary + if (bt is TextBoundaryType.WordStart or TextBoundaryType.WordEnd) + { + var end = offset; + var start = StringUtils.PreviousWord(text, end); + if (start >= end) + start = StringUtils.PreviousWord(text, start); + + if (start < 0 || start >= text.Length || !StringUtils.IsStartOfWord(text, start)) + return ValueTask.FromResult((string.Empty, 0, 0)); + + end = Math.Min(StringUtils.NextWord(text, start), end); + if (end <= start) + return ValueTask.FromResult((string.Empty, 0, 0)); + + return ValueTask.FromResult((text.Substring(start, end - start), start, end)); + } + + // SENTENCE/LINE/PARAGRAPH - return all text before offset + var result = text.Substring(0, offset); + return ValueTask.FromResult((result, 0, offset)); + } + + public ValueTask<(string Text, int StartOffset, int EndOffset)> GetTextAtOffsetAsync( + int offset, uint type) + { + return GetStringAtOffsetAsync(offset, type); + } + + public ValueTask<(string Text, int StartOffset, int EndOffset)> GetTextAfterOffsetAsync( + int offset, uint type) + { + var text = GetText(); + if (offset >= text.Length - 1 || text.Length == 0) + return ValueTask.FromResult((string.Empty, text.Length, text.Length)); + + var bt = (TextBoundaryType)type; + + // CHAR boundary + if (bt == TextBoundaryType.Char) + { + var charOffset = offset + 1; + if (charOffset >= text.Length) + return ValueTask.FromResult((string.Empty, text.Length, text.Length)); + return ValueTask.FromResult((text.Substring(charOffset, 1), charOffset, charOffset + 1)); + } + + // WORD_START or WORD_END boundary + if (bt is TextBoundaryType.WordStart or TextBoundaryType.WordEnd) + { + var start = offset + 1; + + while (start < text.Length && + StringUtils.IsEndOfWord(text, start) && + !StringUtils.IsStartOfWord(text, start)) + { + start++; + } + + if (start >= text.Length) + return ValueTask.FromResult((string.Empty, text.Length, text.Length)); + + var end = Math.Min(StringUtils.NextWord(text, start), text.Length); + if (end <= start) + return ValueTask.FromResult((string.Empty, text.Length, text.Length)); + + return ValueTask.FromResult((text.Substring(start, end - start), start, end)); + } + + // SENTENCE/LINE/PARAGRAPH - return all text after offset + var afterOffset = Math.Max(0, offset + 1); + var result = text.Substring(afterOffset); + return ValueTask.FromResult((result, afterOffset, text.Length)); + } + + public ValueTask GetCharacterAtOffsetAsync(int offset) + { + var text = GetText(); + if (offset < 0 || offset >= text.Length) + return ValueTask.FromResult(unchecked((int)0xFFFFFFFF)); + + return ValueTask.FromResult((int)text[offset]); + } + + public ValueTask GetAttributeValueAsync(int offset, string attributeName) + { + return ValueTask.FromResult(string.Empty); + } + + public ValueTask<(AtSpiAttributeSet Attributes, int StartOffset, int EndOffset)> GetAttributesAsync(int offset) + { + var text = GetText(); + return ValueTask.FromResult((new AtSpiAttributeSet(), 0, text.Length)); + } + + public ValueTask GetDefaultAttributesAsync() + { + return ValueTask.FromResult(new AtSpiAttributeSet()); + } + + public ValueTask<(int X, int Y, int Width, int Height)> GetCharacterExtentsAsync( + int offset, uint coordType) + { + return ValueTask.FromResult((0, 0, 0, 0)); + } + + public ValueTask GetOffsetAtPointAsync(int x, int y, uint coordType) + { + return ValueTask.FromResult(-1); + } + + public ValueTask GetNSelectionsAsync() + { + return ValueTask.FromResult(0); + } + + public ValueTask<(int StartOffset, int EndOffset)> GetSelectionAsync(int selectionNum) + { + return ValueTask.FromResult((0, 0)); + } + + public ValueTask AddSelectionAsync(int startOffset, int endOffset) + { + return ValueTask.FromResult(false); + } + + public ValueTask RemoveSelectionAsync(int selectionNum) + { + return ValueTask.FromResult(false); + } + + public ValueTask SetSelectionAsync(int selectionNum, int startOffset, int endOffset) + { + return ValueTask.FromResult(false); + } + + public ValueTask<(int X, int Y, int Width, int Height)> GetRangeExtentsAsync( + int startOffset, int endOffset, uint coordType) + { + return ValueTask.FromResult((0, 0, 0, 0)); + } + + public ValueTask> GetBoundedRangesAsync( + int x, int y, int width, int height, uint coordType, uint xClipType, uint yClipType) + { + return ValueTask.FromResult(new List()); + } + + public ValueTask<(AtSpiAttributeSet Attributes, int StartOffset, int EndOffset)> GetAttributeRunAsync( + int offset, bool includeDefaults) + { + var text = GetText(); + return ValueTask.FromResult((new AtSpiAttributeSet(), 0, text.Length)); + } + + public ValueTask GetDefaultAttributeSetAsync() + { + return ValueTask.FromResult(new AtSpiAttributeSet()); + } + + public ValueTask ScrollSubstringToAsync(int startOffset, int endOffset, uint type) + { + return ValueTask.FromResult(false); + } + + public ValueTask ScrollSubstringToPointAsync( + int startOffset, int endOffset, uint coordType, int x, int y) + { + return ValueTask.FromResult(false); + } + + private string GetText() + { + return node.Peer.GetProvider()?.Value ?? string.Empty; + } + } +} diff --git a/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiValueHandler.cs b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiValueHandler.cs new file mode 100644 index 0000000000..44eb94ecf4 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/Handlers/AtSpiValueHandler.cs @@ -0,0 +1,46 @@ +using System; +using Avalonia.Automation.Provider; +using Avalonia.FreeDesktop.AtSpi.DBusXml; +using static Avalonia.FreeDesktop.AtSpi.AtSpiConstants; + +namespace Avalonia.FreeDesktop.AtSpi.Handlers +{ + /// + /// Implements the AT-SPI Value interface for range-based controls. + /// + 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.Peer.GetProvider() is { } p ? p.Minimum : 0; + + public double MaximumValue => + _node.Peer.GetProvider() is { } p ? p.Maximum : 0; + + public double MinimumIncrement => + _node.Peer.GetProvider() is { } p ? p.SmallChange : 0; + + public string Text => string.Empty; + + public double CurrentValue + { + get => _node.Peer.GetProvider() is { } p ? p.Value : 0; + set + { + if (_node.Peer.GetProvider() is not { } p) + return; + 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..0a013db432 --- /dev/null +++ b/src/Avalonia.FreeDesktop.AtSpi/RootAtSpiNode.cs @@ -0,0 +1,88 @@ +using System; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.Platform; + +namespace Avalonia.FreeDesktop.AtSpi +{ + /// + /// AT-SPI node for a top-level window with coordinate translation support. + /// + internal sealed class RootAtSpiNode : AtSpiNode + { + public RootAtSpiNode(AutomationPeer peer, AtSpiServer server) + : base(peer, server) + { + RootProvider = peer.GetProvider() ?? throw new InvalidOperationException( + "Attempt to create RootAtSpiNode from peer which does not implement IRootProvider."); + RootProvider.FocusChanged += OnRootFocusChanged; + + if (WindowImpl is not { } impl) + return; + + impl.Activated += OnWindowActivated; + impl.Deactivated += OnWindowDeactivated; + } + + public IRootProvider RootProvider { get; } + public IWindowBaseImpl? WindowImpl => RootProvider.PlatformImpl as IWindowBaseImpl; + public ApplicationAtSpiNode? AppRoot { get; set; } + + 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 = RootProvider.GetFocus(); + var focusedNode = Server.TryGetAttachedNode(focused); + if (focusedNode is null) + { + // Focus can shift before children are queried; + // refresh visible root children lazily. + EnsureChildren(); + focusedNode = Server.TryGetAttachedNode(focused); + } + Server.EmitFocusChange(focusedNode); + } + + private void OnWindowActivated() + { + Server.EmitWindowActivationChange(this, true); + } + + private void OnWindowDeactivated() + { + Server.EmitWindowActivationChange(this, false); + } + + public override void Detach() + { + if (_detached) + return; + + RootProvider.FocusChanged -= OnRootFocusChanged; + + if (WindowImpl is { } impl) + { + impl.Activated -= OnWindowActivated; + impl.Deactivated -= OnWindowDeactivated; + } + + base.Detach(); + } + } +} diff --git a/src/Avalonia.X11/Avalonia.X11.csproj b/src/Avalonia.X11/Avalonia.X11.csproj index 1867711669..4a49edd6bf 100644 --- a/src/Avalonia.X11/Avalonia.X11.csproj +++ b/src/Avalonia.X11/Avalonia.X11.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Avalonia.X11/X11AtSpiAccessibility.cs b/src/Avalonia.X11/X11AtSpiAccessibility.cs new file mode 100644 index 0000000000..18278da34b --- /dev/null +++ b/src/Avalonia.X11/X11AtSpiAccessibility.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Automation.Peers; +using Avalonia.Controls; +using Avalonia.FreeDesktop.AtSpi; +using Avalonia.Logging; +using Avalonia.Threading; + +namespace Avalonia.X11 +{ + internal sealed class X11AtSpiAccessibility + { + private readonly AvaloniaX11Platform _platform; + private readonly List _trackedWindows = new(); + + private AtSpiAccessibilityWatcher? _watcher; + private AtSpiServer? _server; + private bool _serverStartedUnconditionally; + + internal X11AtSpiAccessibility(AvaloniaX11Platform platform) + { + _platform = platform; + } + + internal AtSpiServer? Server => _server; + + internal void Initialize() + { + _watcher = new AtSpiAccessibilityWatcher(); + _ = InitializeAsync(); + } + + internal void TrackWindow(X11Window window) => _trackedWindows.Add(window); + internal void UntrackWindow(X11Window window) => _trackedWindows.Remove(window); + + private async Task InitializeAsync() + { + try + { + await WaitForUiThreadSettleAsync(); + + // Path A: try unconditional connection first (GTK4 approach). + // This avoids delaying startup on watcher/session-bus property calls. + if (await TryStartServerAsync()) + { + _serverStartedUnconditionally = true; + return; + } + + // Path A failed - fall back to watcher-driven enablement. + await _watcher!.InitAsync(); + _watcher.IsEnabledChanged += OnAccessibilityEnabledChanged; + if (_watcher.IsEnabled) + await EnableAccessibilityAsync(); + } + catch (Exception e) + { + Logger.TryGet(LogEventLevel.Warning, LogArea.X11Platform)? + .Log(_platform, "AT-SPI initialization failed and will be disabled: {Exception}", e); + } + } + + private async Task WaitForUiThreadSettleAsync() + { + try + { + // Wait until UI work is drained to context-idle so AT-SPI handlers + // are responsive when clients start querying immediately after embed. + var settle = Dispatcher.UIThread + .InvokeAsync(() => { }, DispatcherPriority.ContextIdle) + .GetTask(); + + // Keep startup bounded in case the UI thread never reaches idle + // (e.g., continuous high-priority work). + await settle.WaitAsync(TimeSpan.FromMilliseconds(100)); + } + catch (TimeoutException e) + { + Logger.TryGet(LogEventLevel.Debug, LogArea.X11Platform)? + .Log(_platform, "AT-SPI startup wait timed out before UI thread reached idle: {Exception}", e); + } + catch (Exception e) + { + Logger.TryGet(LogEventLevel.Debug, LogArea.X11Platform)? + .Log(_platform, "AT-SPI startup wait failed, continuing without idle settle: {Exception}", e); + } + } + + private async void OnAccessibilityEnabledChanged(object? sender, bool enabled) + { + try + { + if (enabled) + { + await EnableAccessibilityAsync(); + } + else if (!_serverStartedUnconditionally) + { + // Only tear down if server wasn't started unconditionally. + // When started unconditionally, event listener tracking handles suppression. + await DisableAccessibilityAsync(); + } + } + catch (Exception e) + { + Logger.TryGet(LogEventLevel.Warning, LogArea.X11Platform)? + .Log(_platform, "AT-SPI dynamic enable/disable toggle failed: {Exception}", e); + } + } + + private async Task TryStartServerAsync() + { + if (_server is not null) + return true; + + try + { + var server = new AtSpiServer(); + await server.StartAsync(); + _server = server; + + // Register any already-tracked windows. + foreach (var window in _trackedWindows) + { + var peer = TryGetWindowPeer(window); + if (peer is not null) + server.AddWindow(peer); + } + + return true; + } + catch (Exception e) + { + Logger.TryGet(LogEventLevel.Warning, LogArea.X11Platform)? + .Log(_platform, "AT-SPI server startup attempt failed: {Exception}", e); + return false; + } + } + + private static AutomationPeer? TryGetWindowPeer(X11Window window) + { + try + { + if (window.InputRoot.RootElement is Control control) + return ControlAutomationPeer.CreatePeerForElement(control); + } + catch (Exception e) + { + // Window can be tracked before input root is available. + Logger.TryGet(LogEventLevel.Debug, LogArea.X11Platform)? + .Log(window, "AT-SPI could not resolve window automation peer yet: {Exception}", e); + } + + return null; + } + + private async Task EnableAccessibilityAsync() + { + await TryStartServerAsync(); + } + + private async Task DisableAccessibilityAsync() + { + if (_server is null) + return; + + var server = _server; + _server = null; + await server.DisposeAsync(); + } + } +} diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index 62be01cbce..e685ff4afb 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using Avalonia.Controls.Platform; using Avalonia.FreeDesktop; +using Avalonia.FreeDesktop.AtSpi; using Avalonia.FreeDesktop.DBusIme; using Avalonia.Input; using Avalonia.Input.Platform; @@ -27,6 +28,8 @@ namespace Avalonia.X11 internal class AvaloniaX11Platform : IWindowingPlatform { private Lazy _keyboardDevice = new Lazy(() => new KeyboardDevice()); + private X11AtSpiAccessibility? _accessibility; + internal AtSpiServer? AtSpiServer => _accessibility?.Server; public KeyboardDevice KeyboardDevice => _keyboardDevice.Value; public Dictionary Windows { get; } = new (); public XI2Manager? XI2 { get; private set; } @@ -106,8 +109,14 @@ namespace Avalonia.X11 Compositor = new Compositor(graphics); AvaloniaLocator.CurrentMutable.Bind().ToConstant(Compositor); + + _accessibility = new X11AtSpiAccessibility(this); + _accessibility.Initialize(); } + internal void TrackWindow(X11Window window) => _accessibility?.TrackWindow(window); + internal void UntrackWindow(X11Window window) => _accessibility?.UntrackWindow(window); + public IntPtr DeferredDisplay { get; set; } public IntPtr Display { get; set; } diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 26a3e7ef08..1e032f446f 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -1026,6 +1026,16 @@ namespace Avalonia.X11 return; _cleaningUp = true; + // Remove from AT-SPI tree before closing + _platform.UntrackWindow(this); + if (_platform.AtSpiServer is { } atSpiServer + && _inputRoot?.RootElement is Control atSpiControl) + { + var atSpiPeer = atSpiControl.GetAutomationPeer(); + if (atSpiPeer is not null) + atSpiServer.RemoveWindow(atSpiPeer); + } + // Before doing anything else notify the TopLevel that ITopLevelImpl is no longer valid if (_handle != IntPtr.Zero) Closed?.Invoke(); @@ -1112,6 +1122,15 @@ namespace Avalonia.X11 public void Show(bool activate, bool isDialog) { _mode.Show(activate, isDialog); + + _platform.TrackWindow(this); + if (_platform.AtSpiServer is { } server + && _inputRoot?.RootElement is Control c) + { + var peer = Avalonia.Automation.Peers.ControlAutomationPeer.CreatePeerForElement(c); + if (peer is not null) + server.AddWindow(peer); + } } public void Hide() => _mode.Hide(); diff --git a/src/tools/Avalonia.DBus.Generators/Avalonia.DBus.Generators.csproj b/src/tools/Avalonia.DBus.Generators/Avalonia.DBus.Generators.csproj new file mode 100644 index 0000000000..6e4868acd4 --- /dev/null +++ b/src/tools/Avalonia.DBus.Generators/Avalonia.DBus.Generators.csproj @@ -0,0 +1,28 @@ + + + netstandard2.0 + latest + enable + false + embedded + false + false + ../../../external/Avalonia.DBus/src/Avalonia.DBus.SourceGen + + + + + + + + + + + + + + + + diff --git a/src/tools/Avalonia.DBus.Generators/Avalonia.DBus.Generators.props b/src/tools/Avalonia.DBus.Generators/Avalonia.DBus.Generators.props new file mode 100644 index 0000000000..e6f0ff2304 --- /dev/null +++ b/src/tools/Avalonia.DBus.Generators/Avalonia.DBus.Generators.props @@ -0,0 +1,6 @@ + + + + + +