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