gpui: Accesskit support (#56065)

GPUI AccessKit integration

This PR is replacing #51097 , and is much more limited in scope. This PR
*ONLY* adds AccessKit support to GPUI, and doesn't touch Zed. Once this
lands, we can start adding aria attributes to Zed's components.

This PR is the first step to addressing #41138 .

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: John Tur <john-tur@outlook.com>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
Cameron Mcloughlin 2026-05-27 19:17:59 +01:00 committed by GitHub
parent bf4e559347
commit 1d029c5ff5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2738 additions and 58 deletions

3
.gitignore vendored
View file

@ -55,3 +55,6 @@ crates/docs_preprocessor/actions.json
# Local documentation audit files # Local documentation audit files
/december-2025-releases.md /december-2025-releases.md
/docs/december-2025-documentation-gaps.md /docs/december-2025-documentation-gaps.md
# NixOS integration test state
.nixos-test-history

388
Cargo.lock generated
View file

@ -2,6 +2,85 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "accesskit"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a"
dependencies = [
"uuid",
]
[[package]]
name = "accesskit_atspi_common"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "842fd8203e6dfcf531d24f5bac792088edfba7d6b35844fead191603fb32a260"
dependencies = [
"accesskit",
"accesskit_consumer",
"atspi-common",
"phf 0.13.1",
"serde",
"zvariant",
]
[[package]]
name = "accesskit_consumer"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_macos"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "534bc3fdc89a64a1db3c46b33c198fde2b7c3c7d094e5809c8c8bf2970c18243"
dependencies = [
"accesskit",
"accesskit_consumer",
"hashbrown 0.16.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
[[package]]
name = "accesskit_unix"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90e549dd7c6562b6a2ea807b44726e6241707db054a817dc4c7e2b8d3b39bfac"
dependencies = [
"accesskit",
"accesskit_atspi_common",
"async-channel 2.5.0",
"async-executor",
"async-task",
"atspi",
"futures-lite 2.6.1",
"futures-util",
"serde",
"zbus",
]
[[package]]
name = "accesskit_windows"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
dependencies = [
"accesskit",
"accesskit_consumer",
"hashbrown 0.16.1",
"static_assertions",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]] [[package]]
name = "acp_thread" name = "acp_thread"
version = "0.1.0" version = "0.1.0"
@ -1235,6 +1314,43 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atspi"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
dependencies = [
"atspi-common",
"atspi-proxies",
]
[[package]]
name = "atspi-common"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
dependencies = [
"enumflags2",
"serde",
"static_assertions",
"zbus",
"zbus-lockstep",
"zbus-lockstep-macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "atspi-proxies"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
dependencies = [
"atspi-common",
"serde",
"zbus",
]
[[package]] [[package]]
name = "audio" name = "audio"
version = "0.1.0" version = "0.1.0"
@ -2183,13 +2299,22 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block2"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
dependencies = [
"objc2 0.5.2",
]
[[package]] [[package]]
name = "block2" name = "block2"
version = "0.6.2" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [ dependencies = [
"objc2", "objc2 0.6.3",
] ]
[[package]] [[package]]
@ -3066,7 +3191,7 @@ dependencies = [
"http_client_tls", "http_client_tls",
"httparse", "httparse",
"log", "log",
"objc2-foundation", "objc2-foundation 0.3.2",
"parking_lot", "parking_lot",
"paths", "paths",
"postage", "postage",
@ -4002,13 +4127,13 @@ dependencies = [
"ndk-context", "ndk-context",
"num-derive", "num-derive",
"num-traits", "num-traits",
"objc2", "objc2 0.6.3",
"objc2-audio-toolbox", "objc2-audio-toolbox",
"objc2-avf-audio", "objc2-avf-audio",
"objc2-core-audio", "objc2-core-audio",
"objc2-core-audio-types", "objc2-core-audio-types",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation 0.3.2",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
@ -5209,9 +5334,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2", "block2 0.6.2",
"libc", "libc",
"objc2", "objc2 0.6.3",
] ]
[[package]] [[package]]
@ -7964,6 +8089,7 @@ dependencies = [
name = "gpui" name = "gpui"
version = "0.2.2" version = "0.2.2"
dependencies = [ dependencies = [
"accesskit",
"anyhow", "anyhow",
"async-channel 2.5.0", "async-channel 2.5.0",
"async-task", "async-task",
@ -8007,8 +8133,8 @@ dependencies = [
"metal", "metal",
"num_cpus", "num_cpus",
"objc", "objc",
"objc2", "objc2 0.6.3",
"objc2-metal", "objc2-metal 0.3.2",
"parking", "parking",
"parking_lot", "parking_lot",
"pathfinder_geometry", "pathfinder_geometry",
@ -8054,6 +8180,8 @@ dependencies = [
name = "gpui_linux" name = "gpui_linux"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"accesskit",
"accesskit_unix",
"anyhow", "anyhow",
"as-raw-xcb-connection", "as-raw-xcb-connection",
"ashpd", "ashpd",
@ -8102,6 +8230,8 @@ dependencies = [
name = "gpui_macos" name = "gpui_macos"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"accesskit",
"accesskit_macos",
"anyhow", "anyhow",
"async-task", "async-task",
"block", "block",
@ -8128,7 +8258,7 @@ dependencies = [
"media", "media",
"metal", "metal",
"objc", "objc",
"objc2-app-kit", "objc2-app-kit 0.3.1",
"parking_lot", "parking_lot",
"pathfinder_geometry", "pathfinder_geometry",
"raw-window-handle", "raw-window-handle",
@ -8246,6 +8376,8 @@ dependencies = [
name = "gpui_windows" name = "gpui_windows"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"accesskit",
"accesskit_windows",
"anyhow", "anyhow",
"collections", "collections",
"etagere", "etagere",
@ -12076,6 +12208,22 @@ dependencies = [
"objc_id", "objc_id",
] ]
[[package]]
name = "objc-sys"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
[[package]]
name = "objc2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
dependencies = [
"objc-sys",
"objc2-encode",
]
[[package]] [[package]]
name = "objc2" name = "objc2"
version = "0.6.3" version = "0.6.3"
@ -12085,14 +12233,30 @@ dependencies = [
"objc2-encode", "objc2-encode",
] ]
[[package]]
name = "objc2-app-kit"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.10.0",
"block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data",
"objc2-core-image",
"objc2-foundation 0.2.2",
"objc2-quartz-core 0.2.2",
]
[[package]] [[package]]
name = "objc2-app-kit" name = "objc2-app-kit"
version = "0.3.1" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
dependencies = [ dependencies = [
"objc2", "objc2 0.6.3",
"objc2-foundation", "objc2-foundation 0.3.2",
] ]
[[package]] [[package]]
@ -12103,11 +12267,11 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"libc", "libc",
"objc2", "objc2 0.6.3",
"objc2-core-audio", "objc2-core-audio",
"objc2-core-audio-types", "objc2-core-audio-types",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation 0.3.2",
] ]
[[package]] [[package]]
@ -12116,8 +12280,8 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [ dependencies = [
"objc2", "objc2 0.6.3",
"objc2-foundation", "objc2-foundation 0.3.2",
] ]
[[package]] [[package]]
@ -12127,10 +12291,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [ dependencies = [
"dispatch2", "dispatch2",
"objc2", "objc2 0.6.3",
"objc2-core-audio-types", "objc2-core-audio-types",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation 0.3.2",
] ]
[[package]] [[package]]
@ -12140,7 +12304,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"objc2", "objc2 0.6.3",
]
[[package]]
name = "objc2-core-data"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.10.0",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
] ]
[[package]] [[package]]
@ -12150,10 +12326,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2", "block2 0.6.2",
"dispatch2", "dispatch2",
"libc", "libc",
"objc2", "objc2 0.6.3",
]
[[package]]
name = "objc2-core-image"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal 0.2.2",
] ]
[[package]] [[package]]
@ -12162,6 +12350,18 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.10.0",
"block2 0.5.1",
"libc",
"objc2 0.5.2",
]
[[package]] [[package]]
name = "objc2-foundation" name = "objc2-foundation"
version = "0.3.2" version = "0.3.2"
@ -12169,9 +12369,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2", "block2 0.6.2",
"libc", "libc",
"objc2", "objc2 0.6.3",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@ -12185,6 +12385,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-metal"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.10.0",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
[[package]] [[package]]
name = "objc2-metal" name = "objc2-metal"
version = "0.3.2" version = "0.3.2"
@ -12192,11 +12404,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"block2", "block2 0.6.2",
"dispatch2", "dispatch2",
"objc2", "objc2 0.6.3",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.10.0",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal 0.2.2",
] ]
[[package]] [[package]]
@ -12206,10 +12431,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"objc2", "objc2 0.6.3",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation 0.3.2",
"objc2-metal", "objc2-metal 0.3.2",
] ]
[[package]] [[package]]
@ -14642,6 +14867,16 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quick-xml"
version = "0.39.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@ -14953,10 +15188,10 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [ dependencies = [
"objc2", "objc2 0.6.3",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation 0.3.2",
"objc2-quartz-core", "objc2-quartz-core 0.3.2",
] ]
[[package]] [[package]]
@ -19204,7 +19439,7 @@ dependencies = [
"toml_datetime 0.7.3", "toml_datetime 0.7.3",
"toml_parser", "toml_parser",
"toml_writer", "toml_writer",
"winnow", "winnow 0.7.13",
] ]
[[package]] [[package]]
@ -19236,7 +19471,7 @@ dependencies = [
"serde_spanned 0.6.9", "serde_spanned 0.6.9",
"toml_datetime 0.6.11", "toml_datetime 0.6.11",
"toml_write", "toml_write",
"winnow", "winnow 0.7.13",
] ]
[[package]] [[package]]
@ -19248,7 +19483,7 @@ dependencies = [
"indexmap 2.11.4", "indexmap 2.11.4",
"toml_datetime 0.7.3", "toml_datetime 0.7.3",
"toml_parser", "toml_parser",
"winnow", "winnow 0.7.13",
] ]
[[package]] [[package]]
@ -19257,7 +19492,7 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
dependencies = [ dependencies = [
"winnow", "winnow 0.7.13",
] ]
[[package]] [[package]]
@ -19506,8 +19741,8 @@ dependencies = [
"chrono", "chrono",
"libc", "libc",
"log", "log",
"objc2", "objc2 0.6.3",
"objc2-foundation", "objc2-foundation 0.3.2",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"scopeguard", "scopeguard",
@ -21404,7 +21639,7 @@ dependencies = [
"ash", "ash",
"bit-set 0.9.1", "bit-set 0.9.1",
"bitflags 2.10.0", "bitflags 2.10.0",
"block2", "block2 0.6.2",
"bytemuck", "bytemuck",
"cfg-if", "cfg-if",
"cfg_aliases 0.2.1", "cfg_aliases 0.2.1",
@ -21420,11 +21655,11 @@ dependencies = [
"log", "log",
"naga", "naga",
"ndk-sys", "ndk-sys",
"objc2", "objc2 0.6.3",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation 0.3.2",
"objc2-metal", "objc2-metal 0.3.2",
"objc2-quartz-core", "objc2-quartz-core 0.3.2",
"once_cell", "once_cell",
"ordered-float 4.6.0", "ordered-float 4.6.0",
"parking_lot", "parking_lot",
@ -22359,6 +22594,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winnow"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "winreg" name = "winreg"
version = "0.10.1" version = "0.10.1"
@ -23179,12 +23423,36 @@ dependencies = [
"uds_windows", "uds_windows",
"uuid", "uuid",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winnow", "winnow 0.7.13",
"zbus_macros", "zbus_macros",
"zbus_names", "zbus_names",
"zvariant", "zvariant",
] ]
[[package]]
name = "zbus-lockstep"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
dependencies = [
"zbus_xml",
"zvariant",
]
[[package]]
name = "zbus-lockstep-macros"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus-lockstep",
"zbus_xml",
"zvariant",
]
[[package]] [[package]]
name = "zbus_macros" name = "zbus_macros"
version = "5.13.2" version = "5.13.2"
@ -23202,12 +23470,24 @@ dependencies = [
[[package]] [[package]]
name = "zbus_names" name = "zbus_names"
version = "4.3.1" version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [ dependencies = [
"serde", "serde",
"winnow", "winnow 1.0.2",
"zvariant",
]
[[package]]
name = "zbus_xml"
version = "5.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9"
dependencies = [
"quick-xml 0.39.3",
"serde",
"zbus_names",
"zvariant", "zvariant",
] ]
@ -23859,24 +24139,24 @@ dependencies = [
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.9.2" version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
dependencies = [ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
"serde", "serde",
"serde_bytes", "serde_bytes",
"winnow", "winnow 1.0.2",
"zvariant_derive", "zvariant_derive",
"zvariant_utils", "zvariant_utils",
] ]
[[package]] [[package]]
name = "zvariant_derive" name = "zvariant_derive"
version = "5.9.2" version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
@ -23887,13 +24167,13 @@ dependencies = [
[[package]] [[package]]
name = "zvariant_utils" name = "zvariant_utils"
version = "3.3.0" version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde", "serde",
"syn 2.0.117", "syn 2.0.117",
"winnow", "winnow 1.0.2",
] ]

View file

@ -503,6 +503,10 @@ ztracing_macro = { path = "crates/ztracing_macro" }
# External crates # External crates
# #
accesskit = "0.24.0"
accesskit_macos = "0.26.0"
accesskit_unix = "0.21.0"
accesskit_windows = "0.32.1"
agent-client-protocol = { version = "=0.12.1", features = ["unstable"] } agent-client-protocol = { version = "=0.12.1", features = ["unstable"] }
aho-corasick = "1.1" aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" }

View file

@ -45,6 +45,7 @@ path = "src/gpui.rs"
doctest = false doctest = false
[dependencies] [dependencies]
accesskit.workspace = true
anyhow.workspace = true anyhow.workspace = true
async-task = "4.7" async-task = "4.7"
backtrace = { workspace = true, optional = true } backtrace = { workspace = true, optional = true }
@ -175,6 +176,8 @@ cbindgen = { version = "0.28.0", default-features = false }
[[example]] [[example]]
name = "hello_world" name = "hello_world"
path = "examples/hello_world.rs" path = "examples/hello_world.rs"
@ -250,3 +253,7 @@ path = "examples/list_example.rs"
[[example]] [[example]]
name = "mouse_pressure" name = "mouse_pressure"
path = "examples/mouse_pressure.rs" path = "examples/mouse_pressure.rs"
[[example]]
name = "a11y"
path = "examples/a11y.rs"

View file

@ -12,6 +12,7 @@ gpui = { version = "*" }
``` ```
- [Ownership and data flow](_ownership_and_data_flow) - [Ownership and data flow](_ownership_and_data_flow)
- [Accessibility](_accessibility)
Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example.

View file

@ -0,0 +1,264 @@
//! Accessibility (AccessKit) demo app.
//!
//! Run with: `cargo run -p gpui --example a11y`
//!
//! Or on Linux: `cargo run -p gpui --features gpui_platform/wayland,gpui_platform/x11 --example a11y`
//!
//! This app uses GPUI's accessibility APIs to attach structured information to
//! the element tree, which allows assistive technology to see and interact with
//! the UI programmatically.
//!
//! The app behaves as follows:
//! - It opens a single window.
//! - The window's title is "GPUI Accessibility Demo".
//! - The window has a sequence of UI elements, stacked vertically:
//! - A heading with the text "Accessibility Demo".
//! - A row containing two elements:
//! - A spin button (role `SpinButton`) labelled "Counter: <n>", where
//! `<n>` is the current count. It supports `Increment` and `Decrement`
//! accessible actions, and also increments on click. The numeric value
//! is clamped to a minimum of 0.
//! - A button labelled "Reset counter" that resets the count to 0.
//! - A row containing two elements:
//! - A switch, that can be toggled, and starts disabled. Toggling the switch
//! does nothing.
//! - The text "Enable feature".
//! - A "to-do" list, with three items, each represented with a `Text` element:
//! - "1. Write code"
//! - "2. Run tests"
//! - "3. Ship it"
use gpui::{
AccessibleAction, App, Bounds, Context, FocusHandle, KeyBinding, Role, SharedString, Toggled,
Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, text,
};
use gpui_platform::application;
actions!(a11y_example, [Tab, TabPrev]);
struct A11yDemo {
focus_handle: FocusHandle,
count: i32,
enabled: bool,
}
impl A11yDemo {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
window.focus(&focus_handle, cx);
Self {
focus_handle,
count: 0,
enabled: false,
}
}
}
impl Render for A11yDemo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.id("root")
.role(Role::Application)
.aria_label("Accessibility Demo")
.track_focus(&self.focus_handle)
.on_action(cx.listener(|_, _: &Tab, window, cx| window.focus_next(cx)))
.on_action(cx.listener(|_, _: &TabPrev, window, cx| window.focus_prev(cx)))
.size_full()
.flex()
.flex_col()
.gap_4()
.p_4()
.bg(rgb(0x1e1e2e))
.text_color(rgb(0xcdd6f4))
// Heading
.child(
div()
.id("heading")
.role(Role::Heading)
.aria_level(1)
.aria_label("Accessibility Demo")
.text_xl()
.font_weight(gpui::FontWeight::BOLD)
.child(text!("Accessibility Demo")),
)
// Counter — uses a SpinButton role with Increment/Decrement
// actions so screen readers can adjust the value directly.
// Click also works via the built-in handler.
.child(
div()
.flex()
.items_center()
.gap_3()
.child(
div()
.id("counter")
.focusable()
.tab_stop(true)
.role(Role::SpinButton)
.aria_label(SharedString::from(format!("Counter: {}", self.count)))
.aria_numeric_value(self.count as f64)
.aria_min_numeric_value(0.0)
.on_a11y_action(AccessibleAction::Increment, {
let this = cx.entity().downgrade();
move |_, _, cx| {
this.update(cx, |this, cx| {
this.count += 1;
cx.notify();
})
.ok();
}
})
.on_a11y_action(AccessibleAction::Decrement, {
let this = cx.entity().downgrade();
move |_, _, cx| {
this.update(cx, |this, cx| {
this.count = (this.count - 1).max(0);
cx.notify();
})
.ok();
}
})
.on_click(cx.listener(|this, _, _, cx| {
this.count += 1;
cx.notify();
}))
.px_3()
.py_1()
.rounded_md()
.bg(rgb(0x89b4fa))
.text_color(rgb(0x1e1e2e))
.cursor_pointer()
.child(text!(format!("Count: {}", self.count))),
)
.child(
div()
.id("reset")
.focusable()
.tab_stop(true)
.role(Role::Button)
.aria_label("Reset counter")
.px_3()
.py_1()
.rounded_md()
.bg(rgb(0x585b70))
.cursor_pointer()
.on_click(cx.listener(|this, _, _, cx| {
this.count = 0;
cx.notify();
}))
.child(text!("Reset")),
),
)
// A toggle switch
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.id("toggle")
.focusable()
.tab_stop(true)
.role(Role::Switch)
.aria_label("Enable feature")
.aria_toggled(if self.enabled {
Toggled::True
} else {
Toggled::False
})
.w(px(44.))
.h(px(24.))
.rounded_full()
.cursor_pointer()
.when(self.enabled, |el| el.bg(rgb(0x89b4fa)))
.when(!self.enabled, |el| el.bg(rgb(0x585b70)))
.child(
div()
.size(px(20.))
.rounded_full()
.bg(gpui::white())
.mt(px(2.))
.when(self.enabled, |el| el.ml(px(22.)))
.when(!self.enabled, |el| el.ml(px(2.))),
)
.on_click(cx.listener(|this, _, _, cx| {
this.enabled = !this.enabled;
cx.notify();
})),
)
.child(text!("Enable feature")),
)
// A short list
.child(
div()
.id("task-list")
.role(Role::List)
.aria_label("Tasks")
.flex()
.flex_col()
.gap_1()
.children(
["Write code", "Run tests", "Ship it"]
.iter()
.enumerate()
.map(|(i, label)| {
div()
.id(("task", i))
.role(Role::ListItem)
.aria_label(SharedString::from(*label))
.aria_position_in_set(i + 1)
.aria_size_of_set(3)
.py_1()
.px_2()
// Note: even though this `text!` macro
// produces multiple elements, it doesn't
// need its own unique ID because the parent
// div has different IDs for each string.
.child(text!(format!("{}. {}", i + 1, label)))
}),
),
)
}
}
fn run_example() {
application().run(|cx: &mut App| {
cx.bind_keys([
KeyBinding::new("tab", Tab, None),
KeyBinding::new("shift-tab", TabPrev, None),
]);
let bounds = Bounds::centered(None, size(px(500.), px(400.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(gpui::TitlebarOptions {
title: Some("GPUI Accessibility Demo".into()),
..Default::default()
}),
..Default::default()
},
|window, cx| cx.new(|cx| A11yDemo::new(window, cx)),
)
.unwrap();
cx.activate(true);
});
}
#[cfg(not(target_family = "wasm"))]
fn main() {
env_logger::builder()
.filter_level(log::LevelFilter::Warn)
.filter_module("gpui", log::LevelFilter::Info)
.init();
run_example();
}
#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
gpui_platform::web_init();
run_example();
}

View file

@ -0,0 +1,243 @@
//! # Accessibility in GPUI
//!
//! "Accessibility" refers to the ability of your application to be used by all
//! users, regardless of disability status. There are many aspects, all important, including:
//! - Ensuring sufficient text contrast.
//! - Providing a mechanism to disable animations.
//! - Providing a mechanism to increase text sizes.
//! - etc.
//!
//! This guide is focused on **programmatic accessibility**. This allows
//! assistive technology, such as screen readers or Braille displays, to inspect
//! and interact with your app.
//!
//! GPUI integrates with [AccessKit] to provide programmatic accessibility
//! features (referred to as simply "accessibility" for the rest of this guide).
//!
//! A minimal example can be found in the `examples/a11y` directory.
//!
//! ## Background
//!
//! Accessibility support is based on two key capabilities:
//! - Exposing information about the current UI state to assistive technology.
//! - Responding to actions requested by assistive technology.
//!
//! For example, a screen reader might want to announce to the user that a new
//! button has appeared. The user may then want to use a voice control program
//! to press that button.
//!
//! ### IDs in GPUI - [`ElementId`] and [`GlobalElementId`]
//!
//! In GPUI, each [`Element`] can have an [`id`][Element::id]:
//! ```rust
//! # use gpui::*;
//! let div_with_id = div().id("my-id").child(text!("hello"));
//!
//! // IDs are optional
//! let div_without_id = div().child(text!("hello"));
//! ```
//!
//! [`Element`]s with IDs are also assigned a [`GlobalElementId`]. This global
//! ID is formed by composing all the non-`None` IDs of its ancestors. For
//! example:
//! ```rust
//! # use gpui::*;
//! let inner = div().id("inner-id");
//! let middle = div().child(inner); // no ID
//! let outer = div().id("outer-id").child(middle);
//! ```
//! In this example, `inner`s global ID is (roughly speaking) `["outer-id",
//! "inner-id"]`.
//!
//! Since `middle` doesn't have an ID itself, it has no global ID.
//!
//! [`GlobalElementId`]s should be unique per-frame. Duplicate global IDs in the
//! same frame will likely cause bugs.
//!
//! ### IDs and accessibility
//!
//! When GPUI renders a frame, it walks your UI tree, and finds nodes with
//! global IDs, and informs assistive technology about this node.
//!
//! In order for nodes to be reported, they must also have a non-`None`
//! [`role`][Element::a11y_role]. This is used to inform assistive technology
//! what *sort* of node it is (button, label, table, etc.). You can use
//! [`div().id(...).role()`][StatefulInteractiveElement::role] to set the role.
//!
//! Nodes with the same global ID *across frames* are considered to be "the
//! same" node. For example:
//! ```rust
//! # use gpui::*;
//! // The UI in frame 1
//! let frame_1 = div()
//! .id("parent")
//! .role(Role::Button)
//! .child(
//! div()
//! .id("id-1")
//! .role(Role::Label)
//! .child(text!("hello"))
//! );
//!
//! // The UI on the next frame
//! let frame_2 = div()
//! .id("parent")
//! .role(Role::Button)
//! .child(
//! div()
//! .id("id-2") // <- different ID
//! .role(Role::Label)
//! .child(text!("hello"))
//! );
//! ```
//! Logically, the UI has not changed. But the screen reader has no way of
//! knowing that both child [`div`]s are "the same". So assistive technology
//! will interpret this as one node being removed, and another node being added.
//! This can be very disorienting for users, since announcements typically only
//! happen when something has *meaningfully* changed.
//!
//! In other words, by controlling the ID of an element, you can control whether
//! a change to a UI element is considered meaningful. You can also control
//! whether elements are reported to assistive technology *at all* by setting
//! the [`role`][Element::a11y_role], since nodes with no role are not reported.
//!
//! #### IDs and text
//!
//! Special care must be taken when dealing with text.
//!
//! GPUI provides the [`text!`] macro, which wraps strings in the [`Text`] type,
//! but automatically derives an ID. Usually, this is what you want. However,
//! the way it generates its ID is subtle and perhaps surprising.
//!
//! The ID of an invocation of the [`text!`] macro is derived from the
//! **location in the source code of that invocation**. For example:
//!
//! ```rust
//! # use gpui::*;
//! let a = text!("a");
//! let b = text!("b");
//!
//! // Different source locations, different IDs
//! assert_ne!(a.id(), b.id());
//!
//! // However:
//!
//! fn make_text(s: &str) -> Text { text!(s) }
//!
//! let a = make_text("a");
//! let b = make_text("b");
//!
//! // Both `a` and `b` are produced by the same `text!` invocation, so the IDs
//! // are the same
//! assert_eq!(a.id(), b.id());
//! ```
//! This can produce surprising behaviour. For example, this footgun:
//! ```rust
//! # use gpui::*;
//! let todos = vec!["eat lunch", "drink water", "go to gym"];
//! let todo_divs = todos.into_iter().map(|todo| {
//! text!(todo)
//! });
//!
//! div()
//! .id("todo-list")
//! .role(Role::Document)
//! .children(todo_divs); // ERROR: multiple nodes with the same global ID
//! ```
//!
//! Here, when we map the iterator, since we have only written [`text!`] once,
//! there is only one ID. And since they have the same ancestors and the same
//! ID, they will have the same global ID. In release builds, this will mean
//! some nodes get silently dropped!
//!
//! To fix this, you can set an ID:
//! ```rust
//! # use gpui::*;
//! let todos = vec!["eat lunch", "drink water", "go to gym"];
//! let todo_divs = todos.into_iter().enumerate().map(|(index, todo)| {
//! text!(todo).with_id(index) // OR `text(id = index, todo)`
//! });
//!
//! div()
//! .id("todo-list")
//! .role(Role::Document)
//! .children(todo_divs);
//! ```
//! Another possible solution is to wrap the [`text!`] in another node that
//! *does* have a unique global ID. For example:
//! ```rust
//! # use gpui::*;
//! let todos = vec!["eat lunch", "drink water", "go to gym"];
//! let todo_divs = todos.into_iter().enumerate().map(|(index, todo)| {
//! div().id(index).child(text!(todo))
//! });
//!
//! div()
//! .id("todo-list")
//! .role(Role::Document)
//! .children(todo_divs);
//! ```
//! Since the AccessKit [`NodeId`][accesskit::NodeId] is derived from the global
//! ID, and the global ID takes into account the IDs of all ancestors, this
//! works too.
//!
//! Occasionally, you will need to create a [`Text`] element with *no* ID. You
//! can achieve this with [`Text::new_inaccessible`]. If you are creating a
//! custom UI component (e.g. a button), you may want this so that you can set a
//! label property on a parent [`div`] without duplicating the text in the
//! accessibility tree.
//!
//! ### Handling actions
//!
//! Assistive technology can dispatch actions to the UI. While many users of
//! assistive technology use traditional input devices (e.g. a keyboard), some
//! use more specialized systems. For example, users with limited mobility may
//! use voice control to interact with your app.
//!
//! When a user dispatches an action, it is dispatched *to a specific node*. It
//! is your responsibility to tell the UI elements how they should respond when
//! a request comes in.
//!
//! Note, these actions are **totally unrelated** to GPUI's [`Action`] trait.
//! AccessKit exposes [`accesskit::Action`]. In GPUI, this is re-exported as
//! [`AccessibleAction`].
//!
//! To respond to an accessible action, use
//! [`div().on_a11y_action()`][InteractiveElement::on_a11y_action]:
//! ```rust,ignore
//! div()
//! .id("my-slider")
//! .role(Role::Slider)
//! .on_a11y_action(AccessibleAction::Increment, |_extra, _window, _cx| {
//! position += 1;
//! cx.notify();
//! })
//! .child(my_cool_slider());
//! ```
//!
//! Note that some common actions are automatically registered. For example,
//! [`.on_click()`][StatefulInteractiveElement::on_click] adds an
//! [`AccessibleAction::Click`] handler that calls the click handler.
//!
//! ## Further reading
//!
//! Designing high-quality accessible interfaces can be challenging, in the same
//! way that designing high-quality traditional interfaces can be. The
//! following pages have useful information:
//!
//! - [AccessKit]: The cross-platform accessibility toolkit GPUI uses
//! internally.
//! - [MDN WAI-ARIA basics][mdn-aria]: Introduction to roles, properties, and
//! states.
//! - [ARIA Authoring Practices Guide][apg]: W3C patterns for accessible
//! widgets.
//!
//! Note that, while GPUI mimics web APIs, it doesn't necessarily behave
//! *exactly* as a web browser would with the same attributes.
//!
//! [AccessKit]: https://accesskit.dev/
//! [mdn-aria]: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Accessibility/WAI-ARIA_basics
//! [apg]: https://www.w3.org/WAI/ARIA/apg/
#[cfg(doc)]
use crate::*; // so I don't have to qualify every type :)

View file

@ -103,6 +103,22 @@ pub trait Element: 'static + IntoElement {
cx: &mut App, cx: &mut App,
); );
/// Returns the accessible role for this element, if any.
/// Elements that return `None` are not included in the accessibility tree.
///
/// Note: inclusion in accessibility tree requires non-`None` [`id`][Element::id].
///
/// See the [accessibility guide](crate::_accessibility) for an overview.
fn a11y_role(&self) -> Option<accesskit::Role> {
None
}
/// Write accessibility properties to the given node.
/// Called only when `a11y_role()` returns `Some`.
///
/// See the [accessibility guide](crate::_accessibility) for an overview.
fn write_a11y_info(&self, _node: &mut accesskit::Node) {}
/// Convert this element into a dynamically-typed [`AnyElement`]. /// Convert this element into a dynamically-typed [`AnyElement`].
fn into_any(self) -> AnyElement { fn into_any(self) -> AnyElement {
AnyElement::new(self) AnyElement::new(self)
@ -302,6 +318,15 @@ impl Display for GlobalElementId {
} }
} }
impl GlobalElementId {
pub(crate) fn accesskit_node_id(&self) -> accesskit::NodeId {
use std::hash::{Hash, Hasher};
let mut hasher = std::hash::DefaultHasher::default();
self.hash(&mut hasher);
accesskit::NodeId(hasher.finish())
}
}
trait ElementObject { trait ElementObject {
fn inner_element(&mut self) -> &mut dyn Any; fn inner_element(&mut self) -> &mut dyn Any;
@ -431,6 +456,26 @@ impl<E: Element> Drawable<E> {
} }
let bounds = window.layout_bounds(layout_id); let bounds = window.layout_bounds(layout_id);
let mut pushed_a11y_node = false;
if window.a11y.is_active() {
if let Some(global_id) = global_id.as_ref() {
if let Some(role) = self.element.a11y_role() {
let node_id = global_id.accesskit_node_id();
let mut node = accesskit::Node::new(role);
let scale = window.scale_factor();
node.set_bounds(accesskit::Rect {
x0: (bounds.origin.x.0 * scale) as f64,
y0: (bounds.origin.y.0 * scale) as f64,
x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale) as f64,
y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64,
});
self.element.write_a11y_info(&mut node);
window.a11y.node_bounds.insert(node_id, bounds);
pushed_a11y_node = window.a11y.nodes.push(node_id, node);
}
}
}
let node_id = window.next_frame.dispatch_tree.push_node(); let node_id = window.next_frame.dispatch_tree.push_node();
let prepaint = self.element.prepaint( let prepaint = self.element.prepaint(
global_id.as_ref(), global_id.as_ref(),
@ -442,6 +487,10 @@ impl<E: Element> Drawable<E> {
); );
window.next_frame.dispatch_tree.pop_node(); window.next_frame.dispatch_tree.pop_node();
if pushed_a11y_node {
window.a11y.nodes.pop();
}
if global_id.is_some() { if global_id.is_some() {
window.element_id_stack.pop(); window.element_id_stack.pop();
} }

View file

@ -1175,6 +1175,124 @@ pub trait InteractiveElement: Sized {
/// A trait for elements that want to use the standard GPUI interactivity features /// A trait for elements that want to use the standard GPUI interactivity features
/// that require state. /// that require state.
pub trait StatefulInteractiveElement: InteractiveElement { pub trait StatefulInteractiveElement: InteractiveElement {
/// Set the accessible role for this element.
///
/// See the [accessibility guide](crate::_accessibility) for an overview.
fn role(mut self, role: accesskit::Role) -> Self {
debug_assert!(
role != accesskit::Role::GenericContainer,
"GenericContainer is filtered out of the a11y tree and has no effect"
);
self.interactivity().override_role = Some(role);
self
}
/// Set the accessible label for this element.
fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.interactivity().aria_label = Some(label.into());
self
}
/// Set the selected state for this element.
fn aria_selected(mut self, selected: bool) -> Self {
self.interactivity().aria_selected = Some(selected);
self
}
/// Set the expanded state for this element.
fn aria_expanded(mut self, expanded: bool) -> Self {
self.interactivity().aria_expanded = Some(expanded);
self
}
/// Set the toggled state for this element.
fn aria_toggled(mut self, toggled: accesskit::Toggled) -> Self {
self.interactivity().aria_toggled = Some(toggled);
self
}
/// Set the numeric value for this element.
fn aria_numeric_value(mut self, value: f64) -> Self {
self.interactivity().aria_numeric_value = Some(value);
self
}
/// Set the minimum numeric value for this element.
fn aria_min_numeric_value(mut self, value: f64) -> Self {
self.interactivity().aria_min_numeric_value = Some(value);
self
}
/// Set the maximum numeric value for this element.
fn aria_max_numeric_value(mut self, value: f64) -> Self {
self.interactivity().aria_max_numeric_value = Some(value);
self
}
/// Set the orientation of this element.
fn aria_orientation(mut self, orientation: accesskit::Orientation) -> Self {
self.interactivity().aria_orientation = Some(orientation);
self
}
/// Set the heading level of this element.
fn aria_level(mut self, level: usize) -> Self {
self.interactivity().aria_level = Some(level);
self
}
/// Set the position in set of this element.
fn aria_position_in_set(mut self, position: usize) -> Self {
self.interactivity().aria_position_in_set = Some(position);
self
}
/// Set the size of set for this element.
fn aria_size_of_set(mut self, size: usize) -> Self {
self.interactivity().aria_size_of_set = Some(size);
self
}
/// Set the row index for this element.
fn aria_row_index(mut self, index: usize) -> Self {
self.interactivity().aria_row_index = Some(index);
self
}
/// Set the column index for this element.
fn aria_column_index(mut self, index: usize) -> Self {
self.interactivity().aria_column_index = Some(index);
self
}
/// Set the row count for this element.
fn aria_row_count(mut self, count: usize) -> Self {
self.interactivity().aria_row_count = Some(count);
self
}
/// Set the column count for this element.
fn aria_column_count(mut self, count: usize) -> Self {
self.interactivity().aria_column_count = Some(count);
self
}
/// Register a handler for an accessibility action on this element.
/// The handler is called when a screen reader requests the given action.
///
/// See the [accessibility guide](crate::_accessibility) for an overview.
fn on_a11y_action(
mut self,
action: accesskit::Action,
listener: impl FnMut(Option<&accesskit::ActionData>, &mut crate::Window, &mut crate::App)
+ 'static,
) -> Self {
self.interactivity()
.a11y_action_listeners
.push((action, Box::new(listener)));
self
}
/// Set this element to focusable. /// Set this element to focusable.
fn focusable(mut self) -> Self { fn focusable(mut self) -> Self {
self.interactivity().focusable = true; self.interactivity().focusable = true;
@ -1474,6 +1592,18 @@ impl Element for Div {
self.interactivity.source_location() self.interactivity.source_location()
} }
fn a11y_role(&self) -> Option<accesskit::Role> {
// Nodes with `GenericContainer` should never be reported to accesskit.
// Equivalent to an HTML div with no role.
self.interactivity
.override_role
.filter(|role| *role != accesskit::Role::GenericContainer)
}
fn write_a11y_info(&self, node: &mut accesskit::Node) {
self.interactivity.write_a11y_info(node);
}
#[stacksafe] #[stacksafe]
fn request_layout( fn request_layout(
&mut self, &mut self,
@ -1710,6 +1840,25 @@ pub struct Interactivity {
pub(crate) tab_group: bool, pub(crate) tab_group: bool,
pub(crate) tab_stop: bool, pub(crate) tab_stop: bool,
pub(crate) a11y_action_listeners:
Vec<(accesskit::Action, crate::window::a11y::A11yActionListener)>,
pub(crate) override_role: Option<accesskit::Role>,
pub(crate) aria_label: Option<SharedString>,
pub(crate) aria_selected: Option<bool>,
pub(crate) aria_expanded: Option<bool>,
pub(crate) aria_toggled: Option<accesskit::Toggled>,
pub(crate) aria_numeric_value: Option<f64>,
pub(crate) aria_min_numeric_value: Option<f64>,
pub(crate) aria_max_numeric_value: Option<f64>,
pub(crate) aria_orientation: Option<accesskit::Orientation>,
pub(crate) aria_level: Option<usize>,
pub(crate) aria_position_in_set: Option<usize>,
pub(crate) aria_size_of_set: Option<usize>,
pub(crate) aria_row_index: Option<usize>,
pub(crate) aria_column_index: Option<usize>,
pub(crate) aria_row_count: Option<usize>,
pub(crate) aria_column_count: Option<usize>,
#[cfg(any(feature = "inspector", debug_assertions))] #[cfg(any(feature = "inspector", debug_assertions))]
pub(crate) source_location: Option<&'static core::panic::Location<'static>>, pub(crate) source_location: Option<&'static core::panic::Location<'static>>,
@ -1830,6 +1979,16 @@ impl Interactivity {
if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { if let Some(focus_handle) = self.tracked_focus_handle.as_ref() {
window.set_focus_handle(focus_handle, cx); window.set_focus_handle(focus_handle, cx);
if window.a11y.is_active() {
if let Some(global_id) = global_id {
let node_id = global_id.accesskit_node_id();
window.a11y.focus_ids.insert(node_id, focus_handle.id);
if focus_handle.is_focused(window) {
window.a11y.nodes.set_focus(node_id);
}
}
}
} }
window.with_optional_element_state::<InteractiveElementState, _>( window.with_optional_element_state::<InteractiveElementState, _>(
global_id, global_id,
@ -2054,6 +2213,22 @@ impl Interactivity {
} }
self.paint_keyboard_listeners(window, cx); self.paint_keyboard_listeners(window, cx);
if window.a11y.is_active() {
if let Some(global_id) = global_id {
if !self.a11y_action_listeners.is_empty() {
let node_id = global_id.accesskit_node_id();
for (action, listener) in
self.a11y_action_listeners.drain(..)
{
window.on_a11y_action(
node_id, action, listener,
);
}
}
}
}
f(&style, window, cx); f(&style, window, cx);
if let Some(_hitbox) = hitbox { if let Some(_hitbox) = hitbox {
@ -2857,6 +3032,63 @@ impl Interactivity {
style style
} }
pub(crate) fn write_a11y_info(&self, node: &mut accesskit::Node) {
if let Some(label) = &self.aria_label {
node.set_label(label.to_string());
}
if let Some(selected) = self.aria_selected {
node.set_selected(selected);
}
if let Some(expanded) = self.aria_expanded {
node.set_expanded(expanded);
}
if let Some(toggled) = self.aria_toggled {
node.set_toggled(toggled);
}
if let Some(value) = self.aria_numeric_value {
node.set_numeric_value(value);
}
if let Some(value) = self.aria_min_numeric_value {
node.set_min_numeric_value(value);
}
if let Some(value) = self.aria_max_numeric_value {
node.set_max_numeric_value(value);
}
if let Some(orientation) = self.aria_orientation {
node.set_orientation(orientation);
}
if let Some(level) = self.aria_level {
node.set_level(level);
}
if let Some(position) = self.aria_position_in_set {
node.set_position_in_set(position);
}
if let Some(size) = self.aria_size_of_set {
node.set_size_of_set(size);
}
if let Some(index) = self.aria_row_index {
node.set_row_index(index);
}
if let Some(index) = self.aria_column_index {
node.set_column_index(index);
}
if let Some(count) = self.aria_row_count {
node.set_row_count(count);
}
if let Some(count) = self.aria_column_count {
node.set_column_count(count);
}
if !self.click_listeners.is_empty() {
node.add_action(accesskit::Action::Click);
}
if self.tracked_focus_handle.is_some() || self.focusable {
node.add_action(accesskit::Action::Focus);
}
for (action, _) in &self.a11y_action_listeners {
node.add_action(*action);
}
}
} }
/// The per-frame state of an interactive element. Used for tracking stateful interactions like clicks /// The per-frame state of an interactive element. Used for tracking stateful interactions like clicks
@ -3263,6 +3495,14 @@ where
self.element.source_location() self.element.source_location()
} }
fn a11y_role(&self) -> Option<accesskit::Role> {
self.element.a11y_role()
}
fn write_a11y_info(&self, node: &mut accesskit::Node) {
self.element.write_a11y_info(node);
}
fn request_layout( fn request_layout(
&mut self, &mut self,
id: Option<&GlobalElementId>, id: Option<&GlobalElementId>,

View file

@ -13,11 +13,244 @@ use std::{
borrow::Cow, borrow::Cow,
cell::{Cell, RefCell}, cell::{Cell, RefCell},
mem, mem,
ops::Range, ops::{Deref, DerefMut, Range},
rc::Rc, rc::Rc,
sync::Arc, sync::Arc,
}; };
/// An [`Element`] that renders text.
///
/// In general, [`Text`] objects should be created via the [`text`] macro:
/// ```rust
/// # use gpui::*;
/// # fn render() -> impl IntoElement {
/// div().child(text!("hello"))
/// # }
/// ```
/// ## IDs and Accessibility
///
/// [`Text`] elements have an ID. This ID is primarily used to produce nodes in
/// the accessibility tree, which allows the text to be visible to screen
/// readers and other assistive technologies.
///
/// This ID is stable across frames. If the same text, with the same ID, is
/// present in two consecutive frames, no updates are reported to the screen
/// reader. If the text changes, but the ID stays the same, then the screen
/// reader will be notified that a text node's content has changed. **However**,
/// if the ID changes, then the screen reader will be notified that a node has
/// been removed, and a new node has been added.
///
/// When using the [`text`] macro, each invocation of the macro will get a
/// unique ID, derived from its position in the source code (filename, line, and
/// column). For example:
/// ```rust
/// # use gpui::*;
/// let x = text!("hello");
/// let y = text!("hello");
/// // not equal, because different `text!` invocations produced them
/// assert_ne!(x.id(), y.id());
///
/// fn make_text(s: &str) -> Text { text!(s) }
/// let x = make_text("hello");
/// let y = make_text("hello");
/// // equal, because the same `text!` invocation produced them
/// assert_eq!(x.id(), y.id());
/// ```
/// When the contents of an invocation of [`text`] do not change, this
/// distinction is less relevant (with the caveat that you still need to take
/// care to ensure that duplicate IDs do not appear).
///
/// However, when a [`text`] invocation's argument *does* change, you should
/// consider whether this change should be reported as a node "updating its
/// contents", or an old node being destroyed and a new node being created.
#[derive(Debug, Clone)]
pub struct Text {
id: Option<ElementId>,
text: SharedString,
}
impl Text {
/// Create a new [`Text`] element with a specific ID.
///
/// If you want a unique ID to be assigned automatically, use the [`text`]
/// macro. The docs for [`Text`] have more detail about choosing IDs.
#[inline]
pub const fn new(id: ElementId, text: SharedString) -> Self {
Self { id: Some(id), text }
}
/// Create a new [`Text`] element that is inaccessible to screen readers.
///
/// In order for text to be accessible to screen readers, it must have an ID
/// provided. If you want text to be accessible, either use [`text`] to have
/// an ID automatically assigned, or use [`Text::new`] to manually assign an
/// ID.
///
/// This function is intended for use inside custom UI components, where
/// accessible properties may be set on parent containers.
#[inline]
pub const fn new_inaccessible(text: SharedString) -> Self {
Self { id: None, text }
}
/// The ID of this [`Text`] element.
#[inline]
pub const fn id(&self) -> Option<&ElementId> {
self.id.as_ref()
}
/// Produce a new [`Text`] with the given `id`.
pub fn with_id(mut self, id: impl Into<ElementId>) -> Self {
self.id = Some(id.into());
self
}
/// The text that this [`Text`] element will display.
#[inline]
pub const fn text(&self) -> &SharedString {
&self.text
}
}
impl Deref for Text {
type Target = SharedString;
fn deref(&self) -> &Self::Target {
&self.text
}
}
impl DerefMut for Text {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.text
}
}
/// Trivial hash function for the location information produced by the [`text`]
/// macro. Not covered by semver guarantees. Performance is not particularly
/// significant because it's only used on small strings in const contexts.
#[doc(hidden)]
pub const fn __hash_text_macro_location_unstable_do_not_use(s: &'static str) -> u64 {
const BASIS: u64 = 0xcbf29ce484222325;
const PRIME: u64 = 0x100000001b3;
let bytes = s.as_bytes();
let mut hash = BASIS;
let mut i = 0;
while i < bytes.len() {
hash ^= bytes[i] as u64;
hash = hash.wrapping_mul(PRIME);
i += 1;
}
hash
}
/// Create a new [`Text`] element.
///
/// ```rust
/// # use gpui::*;
/// let a = text!("hello");
/// let b = text!(id = "farewell-message", "hello");
///
/// ```
///
/// Text created with this macro is *accessible*. The macro generates an ID
/// based on the source location. See the docs for [`Text`] for a more in-depth
/// explanation of the significance of the ID of a [`Text`] element.
#[macro_export]
macro_rules! text {
(id = $id:expr, $text:expr) => {{ $crate::Text::new($id.into(), $text.into()) }};
($text:expr) => {{
const ID: &'static str = concat!(file!(), "/", line!(), ":", column!());
const HASH: u64 = $crate::__hash_text_macro_location_unstable_do_not_use(ID);
$crate::Text::new($crate::ElementId::Integer(HASH), $text.into())
}};
}
impl IntoElement for Text {
type Element = Self;
#[inline]
fn into_element(self) -> Self::Element {
self
}
}
impl Element for Text {
type RequestLayoutState = TextLayout;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
self.id.clone()
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn a11y_role(&self) -> Option<accesskit::Role> {
if self.id.is_some() {
Some(accesskit::Role::Label)
} else {
None
}
}
fn write_a11y_info(&self, node: &mut accesskit::Node) {
node.set_value(self.text.to_string());
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
<SharedString as Element>::request_layout(&mut self.text, id, inspector_id, window, cx)
}
fn prepaint(
&mut self,
id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
<SharedString as Element>::prepaint(
&mut self.text,
id,
inspector_id,
bounds,
request_layout,
window,
cx,
)
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
<SharedString as Element>::paint(
&mut self.text,
id,
inspector_id,
bounds,
request_layout,
prepaint,
window,
cx,
);
}
}
impl Element for &'static str { impl Element for &'static str {
type RequestLayoutState = TextLayout; type RequestLayoutState = TextLayout;
type PrepaintState = (); type PrepaintState = ();
@ -807,6 +1040,14 @@ impl Element for InteractiveText {
None None
} }
fn a11y_role(&self) -> Option<accesskit::Role> {
Some(accesskit::Role::Label)
}
fn write_a11y_info(&self, node: &mut accesskit::Node) {
node.set_value(self.text.text.to_string());
}
fn request_layout( fn request_layout(
&mut self, &mut self,
_id: Option<&GlobalElementId>, _id: Option<&GlobalElementId>,
@ -1009,6 +1250,8 @@ impl IntoElement for InteractiveText {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
#[test] #[test]
fn test_into_element_for() { fn test_into_element_for() {
use crate::{ParentElement as _, SharedString, div}; use crate::{ParentElement as _, SharedString, div};
@ -1019,4 +1262,23 @@ mod tests {
let _ = div().child(Cow::Borrowed("Cow")); let _ = div().child(Cow::Borrowed("Cow"));
let _ = div().child(SharedString::from("SharedString")); let _ = div().child(SharedString::from("SharedString"));
} }
#[test]
fn text_macro_id() {
// one call to `text!` = one id
fn make_text_stable_id(happy: bool) -> Text {
text!(if happy { "happy" } else { "sad" })
}
// two calls to `text!` = two ids
fn make_text_unstable_id(happy: bool) -> Text {
if happy { text!("happy") } else { text!("sad") }
}
assert_eq!(make_text_stable_id(false).id, make_text_stable_id(true).id);
assert_ne!(
make_text_unstable_id(false).id,
make_text_unstable_id(true).id
);
}
} }

View file

@ -56,6 +56,8 @@ mod window;
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub use proptest; pub use proptest;
#[cfg(doc)]
pub mod _accessibility;
#[cfg(doc)] #[cfg(doc)]
pub mod _ownership_and_data_flow; pub mod _ownership_and_data_flow;
@ -75,6 +77,9 @@ mod seal {
pub trait Sealed {} pub trait Sealed {}
} }
pub use accesskit;
pub use accesskit::Action as AccessibleAction;
pub use accesskit::{Orientation, Role, Toggled};
pub use action::*; pub use action::*;
pub use anyhow::Result; pub use anyhow::Result;
pub use app::*; pub use app::*;

View file

@ -591,6 +591,16 @@ impl Tiling {
} }
} }
/// Callbacks for the accessibility adapter.
pub struct A11yCallbacks {
/// Called when the adapter is activated (a screen reader connects).
pub activation: Box<dyn Fn() -> Option<accesskit::TreeUpdate> + Send + 'static>,
/// Called when an action is requested by the screen reader.
pub action: Box<dyn Fn(accesskit::ActionRequest) + Send + 'static>,
/// Called when the adapter is deactivated (screen reader disconnects).
pub deactivation: Box<dyn Fn() + Send + 'static>,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
#[expect(missing_docs)] #[expect(missing_docs)]
pub struct RequestFrameOptions { pub struct RequestFrameOptions {
@ -700,6 +710,15 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
fn play_system_bell(&self) {} fn play_system_bell(&self) {}
/// Initialize the accessibility adapter with callbacks.
fn a11y_init(&self, _callbacks: A11yCallbacks) {}
/// Provide a TreeUpdate to the accessibility adapter.
fn a11y_tree_update(&self, _tree_update: accesskit::TreeUpdate) {}
/// Inform the adapter of updated window bounds.
fn a11y_update_window_bounds(&self) {}
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
fn as_test(&mut self) -> Option<&mut TestWindow> { fn as_test(&mut self) -> Option<&mut TestWindow> {
None None

View file

@ -52,14 +52,18 @@ use std::{
rc::Rc, rc::Rc,
sync::{ sync::{
Arc, Weak, Arc, Weak,
atomic::{AtomicUsize, Ordering::SeqCst}, atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
}, },
time::Duration, time::Duration,
}; };
use uuid::Uuid; use uuid::Uuid;
pub(crate) mod a11y;
mod prompts; mod prompts;
use self::a11y::A11y;
#[cfg(not(target_family = "wasm"))]
use self::a11y::ROOT_NODE_ID;
use crate::util::{ use crate::util::{
atomic_incr_if_not_zero, ceil_to_device_pixel, floor_to_device_pixel, round_half_toward_zero, atomic_incr_if_not_zero, ceil_to_device_pixel, floor_to_device_pixel, round_half_toward_zero,
round_half_toward_zero_f64, round_stroke_to_device_pixel, round_to_device_pixel, round_half_toward_zero_f64, round_stroke_to_device_pixel, round_to_device_pixel,
@ -1021,6 +1025,7 @@ pub struct Window {
captured_hitbox: Option<HitboxId>, captured_hitbox: Option<HitboxId>,
#[cfg(any(feature = "inspector", debug_assertions))] #[cfg(any(feature = "inspector", debug_assertions))]
inspector: Option<Entity<Inspector>>, inspector: Option<Entity<Inspector>>,
pub(crate) a11y: A11y,
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@ -1325,6 +1330,85 @@ impl Window {
WindowBounds::Windowed(_) => {} WindowBounds::Windowed(_) => {}
} }
let a11y_active_flag = Arc::new(AtomicBool::new(false));
#[cfg(not(target_family = "wasm"))]
{
let initial_tree = accesskit::TreeUpdate {
nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))],
tree: Some(accesskit::Tree::new(ROOT_NODE_ID)),
tree_id: accesskit::TreeId::ROOT,
focus: ROOT_NODE_ID,
};
let (activation_sender, activation_receiver) = async_channel::unbounded::<()>();
let (deactivation_sender, deactivation_receiver) = async_channel::unbounded::<()>();
let (action_sender, action_receiver) =
async_channel::unbounded::<accesskit::ActionRequest>();
platform_window.a11y_init(crate::A11yCallbacks {
activation: {
let active_flag = a11y_active_flag.clone();
Box::new(move || {
log::info!("Accessibility activated");
active_flag.store(true, SeqCst);
activation_sender.send_blocking(()).log_err();
Some(initial_tree.clone())
})
},
action: Box::new(move |request| {
action_sender.send_blocking(request).log_err();
}),
deactivation: {
let active_flag = a11y_active_flag.clone();
Box::new(move || {
log::info!("Accessibility deactivated");
active_flag.store(false, SeqCst);
deactivation_sender.send_blocking(()).log_err();
})
},
});
// A11y can be activated at any time, and so we cannot compute a
// correct `TreeUpdate` on-demand. When this happens, we return a
// default empty `TreeUpdate`.
//
// So we force a new frame, which will then send a correct `TreeUpdate`.
let mut async_cx = cx.to_async();
cx.foreground_executor()
.spawn(async move {
while activation_receiver.recv().await.is_ok() {
handle
.update(&mut async_cx, |_, window, _| window.refresh())
.log_err();
}
})
.detach();
let mut async_cx = cx.to_async();
cx.foreground_executor()
.spawn(async move {
while deactivation_receiver.recv().await.is_ok() {
handle
.update(&mut async_cx, |_, window, _| window.refresh())
.log_err();
}
})
.detach();
let mut async_cx = cx.to_async();
cx.foreground_executor()
.spawn(async move {
while let Ok(request) = action_receiver.recv().await {
handle
.update(&mut async_cx, |_, window, cx| {
window.handle_a11y_action(request, cx);
})
.log_err();
}
})
.detach();
}
platform_window.on_close(Box::new({ platform_window.on_close(Box::new({
let window_id = handle.window_id(); let window_id = handle.window_id();
let mut cx = cx.to_async(); let mut cx = cx.to_async();
@ -1633,6 +1717,7 @@ impl Window {
captured_hitbox: None, captured_hitbox: None,
#[cfg(any(feature = "inspector", debug_assertions))] #[cfg(any(feature = "inspector", debug_assertions))]
inspector: None, inspector: None,
a11y: A11y::new(a11y_active_flag),
}) })
} }
@ -2620,6 +2705,11 @@ impl Window {
self.invalidator.set_phase(DrawPhase::Prepaint); self.invalidator.set_phase(DrawPhase::Prepaint);
self.tooltip_bounds.take(); self.tooltip_bounds.take();
self.a11y.sync_active_flag();
if self.a11y.is_active() {
self.a11y.begin_frame();
}
let _inspector_width: Pixels = rems(30.0).to_pixels(self.rem_size()); let _inspector_width: Pixels = rems(30.0).to_pixels(self.rem_size());
let root_size = { let root_size = {
#[cfg(any(feature = "inspector", debug_assertions))] #[cfg(any(feature = "inspector", debug_assertions))]
@ -2686,6 +2776,26 @@ impl Window {
#[cfg(any(feature = "inspector", debug_assertions))] #[cfg(any(feature = "inspector", debug_assertions))]
self.paint_inspector_hitbox(cx); self.paint_inspector_hitbox(cx);
// a11y may have been activated/deactivated halfway through the frame
let a11y_active_start_of_frame = self.a11y.is_active();
self.a11y.sync_active_flag();
let a11y_active_end_of_frame = self.a11y.is_active();
let should_send_a11y_update = a11y_active_start_of_frame && a11y_active_end_of_frame;
if a11y_active_start_of_frame {
// clear the builder state regardless
let tree_update = self.a11y.end_frame();
if should_send_a11y_update {
log::debug!(
"Sending a11y tree update: {} nodes",
tree_update.nodes.len()
);
self.platform_window.a11y_tree_update(tree_update);
}
}
} }
fn prepaint_tooltip(&mut self, cx: &mut App) -> Option<AnyElement> { fn prepaint_tooltip(&mut self, cx: &mut App) -> Option<AnyElement> {
@ -5296,6 +5406,87 @@ impl Window {
self.platform_window.play_system_bell() self.platform_window.play_system_bell()
} }
/// Register a listener for an accessibility action on a specific node.
/// The listener will be called when a screen reader requests the given
/// action on the node identified by `node_id`.
///
/// See the [accessibility guide](crate::_accessibility) for an overview.
pub fn on_a11y_action(
&mut self,
node_id: accesskit::NodeId,
action: accesskit::Action,
listener: impl FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static,
) {
self.a11y
.action_listeners
.entry(node_id)
.or_default()
.push((action, Box::new(listener)));
}
#[cfg(not(target_family = "wasm"))]
pub(crate) fn handle_a11y_action(&mut self, request: accesskit::ActionRequest, cx: &mut App) {
// Take listeners out temporarily so the closures can borrow Window
// mutably, then restore them afterward.
if let Some(mut listeners) = self.a11y.action_listeners.remove(&request.target_node) {
let extra_data = request.data.as_ref();
let mut matched = false;
for (action, listener) in &mut listeners {
if *action == request.action {
listener(extra_data, self, cx);
matched = true;
}
}
self.a11y
.action_listeners
.insert(request.target_node, listeners);
if matched {
return;
}
}
// Fall back to built-in action handling.
match request.action {
accesskit::Action::Click => {
if let Some(bounds) = self.a11y.node_bounds.get(&request.target_node).copied() {
let center = bounds.center();
let mouse_down = PlatformInput::MouseDown(crate::MouseDownEvent {
button: MouseButton::Left,
position: center,
modifiers: Modifiers::default(),
click_count: 1,
first_mouse: false,
});
let mouse_up = PlatformInput::MouseUp(MouseUpEvent {
button: MouseButton::Left,
position: center,
modifiers: Modifiers::default(),
click_count: 1,
});
self.dispatch_event(mouse_down, cx);
self.dispatch_event(mouse_up, cx);
}
}
accesskit::Action::Focus => {
if let Some(focus_id) = self.a11y.focus_ids.get(&request.target_node).copied()
&& let Some(handle) = FocusHandle::for_id(focus_id, &cx.focus_handles)
{
self.focus(&handle, cx);
}
}
accesskit::Action::Blur => {
self.blur();
}
_ => {
log::debug!(
"Unhandled a11y action: {:?} on {:?}",
request.action,
request.target_node
);
}
}
}
/// Toggles the inspector mode on this window. /// Toggles the inspector mode on this window.
#[cfg(any(feature = "inspector", debug_assertions))] #[cfg(any(feature = "inspector", debug_assertions))]
pub fn toggle_inspector(&mut self, cx: &mut App) { pub fn toggle_inspector(&mut self, cx: &mut App) {

View file

@ -0,0 +1,281 @@
//! Accessibility support, provided by [AccessKit][accesskit].
//!
//! There are user-facing guide-level docs [here](crate::_accessibility).
//!
//! ## Architecture
//!
//! ```text
//! ┌────────────────────────────────┐ ┌─────────────────────┐
//! ┌─▶│ AccessKit Adapter (MacOS) │◀─▶│ MacOS System APIs │
//! │ └────────────────────────────────┘ └─────────────────────┘
//! │
//! ┌──────┐ ┌───────────┐ │ ┌────────────────────────────────┐ ┌─────────────────────┐
//! │ GPUI │◀─▶│ AccessKit │◀─┼─▶│ AccessKit Adapter (Windows) │◀─▶│ Windows System APIs │
//! └──────┘ └───────────┘ │ └────────────────────────────────┘ └─────────────────────┘
//! │
//! │ ┌────────────────────────────────┐ ┌─────────────────────┐
//! └─▶│ AccessKit Adapter (Linux) │◀─▶│ dbus │
//! └────────────────────────────────┘ └─────────────────────┘
//! ```
//!
//! In order for GPUI apps to be usable for people using assistive technology,
//! we must do a few things:
//! - Inform the system when the UI changes meaningfully. This includes:
//! - Reporting new/removed/changed UI elements
//! - *Not* reporting irrelevant UI changes, e.g. an invisible `div()` being
//! added.
//! - Reporting the appearance and capabilities of each UI element. For example:
//! - What does this piece of text say?
//! - How far along is this progress bar?
//! - Can this node be focused?
//! - Can this node have a value directly assigned? (e.g. a slider)
//! - Allowing the system to interact with the UI by dispatching actions to
//! nodes. Note that AccessKit has its own [`Action`] type, which is not the
//! [`crate::Action`] trait.
//! - Activate and deactivate accessibility features when requested by the
//! system.
//!
//! Activating and deactivating at the right time is trivial, so I won't go into
//! detail here. The other two are almost orthogonal in implementation.
//!
//! The state for both lives in the [`A11y`] struct in this module.
//!
//! ### Reporting UI changes
//!
//! Every frame, we build a [`TreeUpdate`] and send it to the platform-specific
//! adapter. A [`TreeUpdate`] is a representation of a subset of the UI tree.
//! When the adapter receives the update, it diffs it against the previous
//! update, and calls platform-specific APIs to inform screen readers about the
//! changes. Nodes may have been created, destroyed, or updated.
//!
//! Each node has an ID, and this ID *should* be stable across frames. If a
//! node's ID changes, then, from AccessKit's point of view, it is a different
//! node.
//!
//! We derive the node ID from the [`GlobalElementId`] in
//! [`GlobalElementId::accesskit_node_id`]. Nodes without [`GlobalElementId`]s
//! cannot produce an AccessKit [`NodeId`], and so are not included in the
//! accessibility tree. We try to warn when using accessibility APIs on
//! [`div()`] without setting an ID.
//!
//! This all happens in [`Drawable::prepaint`]. The [`A11y`] struct maintains a
//! stack of nodes during prepainting, which we can use to calculate the
//! [`NodeId`]s, and record parent-child relationships. Once all [`Element`]s in
//! a frame have been prepainted, we send the resulting [`TreeUpdate`] object to
//! the adapter and the screen reader can announce the changes.
//!
//! ### Responding to actions
//!
//! On adapter creation, we provide a callback to the adapter, which can be used
//! to dispatch actions. This callback forwards to [`A11y::action_listeners`], a
//! mapping from [`NodeId`]s to action handlers (basically just `Box<dyn
//! Fn()>`).
//!
//! This is populated in:
//! - [`Window::on_a11y_action`], which is called by:
//! - [`Interactivity::paint`], which is called by:
//! - [`InteractiveElement::on_a11y_action`], which is a public-facing API
//!
//! These are cleared at the start of a frame, and re-populated during painting.
//!
//! [`Element`]: crate::Element
//! [`GlobalElementId`]: crate::GlobalElementId
//! [`div()`]: crate::div
//! [`Interactivity::paint`]: crate::Interactivity::paint
//! [`InteractiveElement::on_a11y_action`]: crate::InteractiveElement::on_a11y_action
//! [`NodeId`]: accesskit::NodeId
//! [`Drawable::prepaint`]: crate::Drawable::prepaint
use crate::{App, Bounds, FocusId, Pixels, Window};
use accesskit::{Action, NodeId, TreeUpdate};
use collections::{FxHashMap, FxHashSet};
use smallvec::SmallVec;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
/// The fixed AccessKit node ID used for the root of every window's a11y tree.
pub(crate) const ROOT_NODE_ID: NodeId = NodeId(0);
/// A listener for an accessibility action on a specific node.
pub(crate) type A11yActionListener =
Box<dyn FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static>;
/// Per-window accessibility state.
///
/// Manages the AccessKit tree that is built each frame and the mappings
/// needed to dispatch incoming action requests back to the right elements.
pub(crate) struct A11y {
/// Whether a11y features have been requested by the system.
///
/// Updated by AccessKit using callbacks provided to the adapter. Can change
/// halfway through a frame.
active_flag: Arc<AtomicBool>,
/// Whether a11y features are active for *this specific frame*.
///
/// At the start of each frame, we load [`Self::active_flag`] (using
/// [`Self::sync_active_flag`]) and use this to determine whether we
/// should construct a [`TreeUpdate`] for this frame. It's important that
/// this value is stable within a frame, because the builder API exposed by
/// this type maintains a stack of nodes and each must be pushed and popped
/// exactly once.
///
/// At the end of the frame, we re-call [`Self::sync_active_flag`] to
/// determine whether we should actually send the finished [`TreeUpdate`].
active_this_frame: bool,
pub(crate) nodes: A11yNodeBuilder,
pub(crate) focus_ids: FxHashMap<NodeId, FocusId>,
pub(crate) node_bounds: FxHashMap<NodeId, Bounds<Pixels>>,
pub(crate) action_listeners: FxHashMap<NodeId, Vec<(Action, A11yActionListener)>>,
}
impl A11y {
pub(crate) fn new(active_flag: Arc<AtomicBool>) -> Self {
Self {
active_flag,
active_this_frame: false,
nodes: A11yNodeBuilder::new(),
focus_ids: FxHashMap::default(),
node_bounds: FxHashMap::default(),
action_listeners: FxHashMap::default(),
}
}
/// Ensures that [`Self::is_active`] returns up to date information.
///
/// See the docs for [`Self::active_flag`] and [`Self::active_this_frame`]
/// for more commentary.
pub(crate) fn sync_active_flag(&mut self) {
self.active_this_frame = self.active_flag.load(Ordering::SeqCst);
}
pub(crate) fn is_active(&self) -> bool {
self.active_this_frame
}
/// Clear per-frame state and push the root node to start a new frame.
pub(crate) fn begin_frame(&mut self) {
self.focus_ids.clear();
self.node_bounds.clear();
self.action_listeners.clear();
self.nodes.begin_frame();
}
/// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter.
pub(crate) fn end_frame(&mut self) -> TreeUpdate {
self.nodes.finalize()
}
}
pub(crate) struct A11yNodeBuilder {
ids_stack: SmallVec<[NodeId; 16]>,
nodes_stack: SmallVec<[accesskit::Node; 16]>,
/// This is the exact type required by accesskit, so we can't just make it a
/// `HashMap<NodeId, Node>` to remove the need for `seen_ids`
all_nodes: Vec<(NodeId, accesskit::Node)>,
seen_ids: FxHashSet<NodeId>,
focus: NodeId,
#[cfg(debug_assertions)]
has_set_focus: bool,
}
impl A11yNodeBuilder {
fn new() -> Self {
Self {
ids_stack: SmallVec::new(),
nodes_stack: SmallVec::new(),
all_nodes: Vec::new(),
seen_ids: FxHashSet::default(),
focus: ROOT_NODE_ID,
#[cfg(debug_assertions)]
has_set_focus: false,
}
}
/// Push a new node onto the stack. It becomes a child of the current
/// top-of-stack node.
///
/// Returns `true` if the node was successfully pushed.
pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool {
debug_assert!(!self.ids_stack.is_empty(), "push called before push_root");
if !self.seen_ids.insert(id) {
debug_assert!(
false,
"Duplicate a11y node id: {id:?}. In a release build, this node would be silently discarded from the a11y tree."
);
// We need to return `false` here because inserting a duplicate
// node will cause a panic in accesskit
return false;
}
if let Some(parent) = self.nodes_stack.last_mut() {
parent.push_child(id);
}
self.ids_stack.push(id);
self.nodes_stack.push(node);
true
}
/// Pop the current node off the stack and finalize it into the all_nodes
/// list.
pub(crate) fn pop(&mut self) {
debug_assert!(self.ids_stack.len() > 1, "pop would remove the root node");
if let (Some(id), Some(node)) = (self.ids_stack.pop(), self.nodes_stack.pop()) {
self.all_nodes.push((id, node));
}
}
/// Push the root node to start a new frame.
fn begin_frame(&mut self) {
self.all_nodes.clear();
self.ids_stack.clear();
self.nodes_stack.clear();
self.seen_ids.clear();
#[cfg(debug_assertions)]
{
self.has_set_focus = false;
}
let root_node = accesskit::Node::new(accesskit::Role::Window);
self.ids_stack.push(ROOT_NODE_ID);
self.nodes_stack.push(root_node);
self.focus = ROOT_NODE_ID;
}
/// Set the focused node for this frame.
pub(crate) fn set_focus(&mut self, id: NodeId) {
#[cfg(debug_assertions)]
{
debug_assert!(
!self.has_set_focus,
"set_focus called more than once in a single frame"
);
self.has_set_focus = true;
}
self.focus = id;
}
fn finalize(&mut self) -> TreeUpdate {
// Stack should contain only the root node
debug_assert_eq!(self.ids_stack.len(), 1);
debug_assert_eq!(self.ids_stack[0], ROOT_NODE_ID);
// Pop remaining nodes (should just be the root).
while !self.ids_stack.is_empty() {
if let (Some(id), Some(node)) = (self.ids_stack.pop(), self.nodes_stack.pop()) {
self.all_nodes.push((id, node));
}
}
let nodes = std::mem::take(&mut self.all_nodes);
TreeUpdate {
nodes,
tree: Some(accesskit::Tree::new(ROOT_NODE_ID)),
tree_id: accesskit::TreeId::ROOT,
focus: self.focus,
}
}
}

View file

@ -51,6 +51,8 @@ screen-capture = [
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
accesskit.workspace = true
accesskit_unix.workspace = true
anyhow.workspace = true anyhow.workspace = true
bytemuck = "1" bytemuck = "1"
collections.workspace = true collections.workspace = true

View file

@ -123,6 +123,7 @@ pub struct WaylandWindowState {
in_progress_window_controls: Option<WindowControls>, in_progress_window_controls: Option<WindowControls>,
window_controls: WindowControls, window_controls: WindowControls,
client_inset: Option<Pixels>, client_inset: Option<Pixels>,
accesskit_adapter: Option<accesskit_unix::Adapter>,
} }
pub enum WaylandSurfaceState { pub enum WaylandSurfaceState {
@ -398,6 +399,7 @@ impl WaylandWindowState {
in_progress_window_controls: None, in_progress_window_controls: None,
window_controls: WindowControls::default(), window_controls: WindowControls::default(),
client_inset: None, client_inset: None,
accesskit_adapter: None,
}) })
} }
@ -1047,6 +1049,9 @@ impl WaylandWindowStatePtr {
fun(focus); fun(focus);
self.callbacks.borrow_mut().active_status_change = Some(fun); self.callbacks.borrow_mut().active_status_change = Some(fun);
} }
if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() {
adapter.update_window_focus_state(focus);
}
} }
pub fn set_hovered(&self, focus: bool) { pub fn set_hovered(&self, focus: bool) {
@ -1519,6 +1524,60 @@ impl PlatformWindow for WaylandWindow {
bell.ring(surface); bell.ring(surface);
} }
} }
fn a11y_init(&self, callbacks: gpui::A11yCallbacks) {
let activation_handler = TrivialActivationHandler {
callback: callbacks.activation,
};
let action_handler = TrivialActionHandler(callbacks.action);
let deactivation_handler = TrivialDeactivationHandler {
callback: callbacks.deactivation,
};
let adapter =
accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler);
self.borrow_mut().accesskit_adapter = Some(adapter);
}
fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) {
let mut state = self.borrow_mut();
if let Some(adapter) = state.accesskit_adapter.as_mut() {
adapter.update_if_active(|| tree_update);
}
}
fn a11y_update_window_bounds(&self) {
// Wayland doesn't expose window position, so this is a no-op
}
}
struct TrivialActivationHandler {
callback: Box<dyn Fn() -> Option<accesskit::TreeUpdate> + Send + 'static>,
}
impl accesskit::ActivationHandler for TrivialActivationHandler {
fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
(self.callback)()
}
}
struct TrivialActionHandler(Box<dyn Fn(accesskit::ActionRequest) + Send + 'static>);
impl accesskit::ActionHandler for TrivialActionHandler {
fn do_action(&mut self, request: accesskit::ActionRequest) {
(self.0)(request);
}
}
struct TrivialDeactivationHandler {
callback: Box<dyn Fn() + Send + 'static>,
}
impl accesskit::DeactivationHandler for TrivialDeactivationHandler {
fn deactivate_accessibility(&mut self) {
(self.callback)();
}
} }
fn update_window(mut state: RefMut<WaylandWindowState>) { fn update_window(mut state: RefMut<WaylandWindowState>) {

View file

@ -285,6 +285,7 @@ pub struct X11WindowState {
edge_constraints: Option<EdgeConstraints>, edge_constraints: Option<EdgeConstraints>,
pub handle: AnyWindowHandle, pub handle: AnyWindowHandle,
last_insets: [u32; 4], last_insets: [u32; 4],
accesskit_adapter: Option<accesskit_unix::Adapter>,
} }
impl X11WindowState { impl X11WindowState {
@ -801,6 +802,7 @@ impl X11WindowState {
decorations: WindowDecorations::Server, decorations: WindowDecorations::Server,
last_insets: [0, 0, 0, 0], last_insets: [0, 0, 0, 0],
edge_constraints: None, edge_constraints: None,
accesskit_adapter: None,
counter_id: sync_request_counter, counter_id: sync_request_counter,
last_sync_counter: None, last_sync_counter: None,
}) })
@ -1277,6 +1279,9 @@ impl X11WindowStatePtr {
fun(focus); fun(focus);
self.callbacks.borrow_mut().active_status_change = Some(fun); self.callbacks.borrow_mut().active_status_change = Some(fun);
} }
if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() {
adapter.update_window_focus_state(focus);
}
} }
pub fn set_hovered(&self, focus: bool) { pub fn set_hovered(&self, focus: bool) {
@ -1886,4 +1891,84 @@ impl PlatformWindow for X11Window {
// Volume 0% means don't increase or decrease from system volume // Volume 0% means don't increase or decrease from system volume
let _ = self.0.xcb.bell(0); let _ = self.0.xcb.bell(0);
} }
fn a11y_init(&self, callbacks: gpui::A11yCallbacks) {
let activation_handler = TrivialActivationHandler {
callback: callbacks.activation,
};
let action_handler = TrivialActionHandler(callbacks.action);
let deactivation_handler = TrivialDeactivationHandler {
callback: callbacks.deactivation,
};
let adapter =
accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler);
self.0.state.borrow_mut().accesskit_adapter = Some(adapter);
}
fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) {
let mut state = self.0.state.borrow_mut();
if let Some(adapter) = state.accesskit_adapter.as_mut() {
adapter.update_if_active(|| tree_update);
}
}
fn a11y_update_window_bounds(&self) {
let mut state = self.0.state.borrow_mut();
let scale = state.scale_factor;
let bounds = state.bounds;
let [left, right, top, bottom] = state.last_insets;
let x = f32::from(bounds.origin.x);
let y = f32::from(bounds.origin.y);
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
let outer = accesskit::Rect {
x0: (x * scale) as f64,
y0: (y * scale) as f64,
x1: ((x + width) * scale) as f64,
y1: ((y + height) * scale) as f64,
};
let inner = accesskit::Rect {
x0: (x * scale) as f64 + left as f64,
y0: (y * scale) as f64 + top as f64,
x1: ((x + width) * scale) as f64 - right as f64,
y1: ((y + height) * scale) as f64 - bottom as f64,
};
if let Some(adapter) = state.accesskit_adapter.as_mut() {
adapter.set_root_window_bounds(outer, inner);
}
}
}
struct TrivialActivationHandler {
callback: Box<dyn Fn() -> Option<accesskit::TreeUpdate> + Send + 'static>,
}
impl accesskit::ActivationHandler for TrivialActivationHandler {
fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
(self.callback)()
}
}
struct TrivialActionHandler(Box<dyn Fn(accesskit::ActionRequest) + Send + 'static>);
impl accesskit::ActionHandler for TrivialActionHandler {
fn do_action(&mut self, request: accesskit::ActionRequest) {
(self.0)(request);
}
}
struct TrivialDeactivationHandler {
callback: Box<dyn Fn() + Send + 'static>,
}
impl accesskit::DeactivationHandler for TrivialDeactivationHandler {
fn deactivate_accessibility(&mut self) {
(self.callback)();
}
} }

View file

@ -22,6 +22,8 @@ screen-capture = ["gpui/screen-capture"]
gpui.workspace = true gpui.workspace = true
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
accesskit.workspace = true
accesskit_macos.workspace = true
anyhow.workspace = true anyhow.workspace = true
async-task = "4.7" async-task = "4.7"
block = "0.1" block = "0.1"

View file

@ -500,6 +500,7 @@ struct MacWindowState {
toggle_tab_bar_callback: Option<Box<dyn FnMut()>>, toggle_tab_bar_callback: Option<Box<dyn FnMut()>>,
activated_least_once: bool, activated_least_once: bool,
closed: Arc<AtomicBool>, closed: Arc<AtomicBool>,
accesskit_adapter: Option<accesskit_macos::SubclassingAdapter>,
// The parent window if this window is a sheet (Dialog kind) // The parent window if this window is a sheet (Dialog kind)
sheet_parent: Option<id>, sheet_parent: Option<id>,
} }
@ -829,6 +830,7 @@ impl MacWindow {
toggle_tab_bar_callback: None, toggle_tab_bar_callback: None,
activated_least_once: false, activated_least_once: false,
closed: Arc::new(AtomicBool::new(false)), closed: Arc::new(AtomicBool::new(false)),
accesskit_adapter: None,
sheet_parent: None, sheet_parent: None,
}))); })));
@ -1730,6 +1732,59 @@ impl PlatformWindow for MacWindow {
let mut this = self.0.lock(); let mut this = self.0.lock();
this.renderer.render_to_image(scene) this.renderer.render_to_image(scene)
} }
fn a11y_init(&self, callbacks: gpui::A11yCallbacks) {
let mut lock = self.0.lock();
let activation_handler = A11yActivationHandler {
callback: callbacks.activation,
};
let action_handler = A11yActionHandler(callbacks.action);
let adapter = unsafe {
accesskit_macos::SubclassingAdapter::for_window(
lock.native_window as *mut c_void,
activation_handler,
action_handler,
)
};
lock.accesskit_adapter = Some(adapter);
}
fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) {
let events = {
let mut lock = self.0.lock();
lock.accesskit_adapter
.as_mut()
.and_then(|adapter| adapter.update_if_active(|| tree_update))
};
if let Some(events) = events {
events.raise();
}
}
fn a11y_update_window_bounds(&self) {
// macOS handles window bounds tracking automatically via NSAccessibility.
}
}
struct A11yActivationHandler {
callback: Box<dyn Fn() -> Option<accesskit::TreeUpdate> + Send + 'static>,
}
impl accesskit::ActivationHandler for A11yActivationHandler {
fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
(self.callback)()
}
}
struct A11yActionHandler(Box<dyn Fn(accesskit::ActionRequest) + Send + 'static>);
impl accesskit::ActionHandler for A11yActionHandler {
fn do_action(&mut self, request: accesskit::ActionRequest) {
(self.0)(request);
}
} }
impl rwh::HasWindowHandle for MacWindow { impl rwh::HasWindowHandle for MacWindow {
@ -2341,6 +2396,16 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id)
let executor = lock.foreground_executor.clone(); let executor = lock.foreground_executor.clone();
drop(lock); drop(lock);
let a11y_events = {
let mut lock = window_state.lock();
lock.accesskit_adapter
.as_mut()
.and_then(|adapter| adapter.update_view_focus_state(is_active))
};
if let Some(events) = a11y_events {
events.raise();
}
// When a window becomes active, trigger an immediate synchronous frame request to prevent // When a window becomes active, trigger an immediate synchronous frame request to prevent
// tab flicker when switching between windows in native tabs mode. // tab flicker when switching between windows in native tabs mode.
// //

View file

@ -20,6 +20,8 @@ screen-capture = ["gpui/screen-capture", "scap"]
gpui.workspace = true gpui.workspace = true
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
accesskit.workspace = true
accesskit_windows.workspace = true
anyhow.workspace = true anyhow.workspace = true
collections.workspace = true collections.workspace = true
etagere = "0.2" etagere = "0.2"

View file

@ -112,6 +112,7 @@ impl WindowsWindowInner {
WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true), WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true),
WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam),
DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam), DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam),
WM_GETOBJECT => self.handle_wm_getobject(wparam, lparam),
_ => None, _ => None,
}; };
if let Some(n) = handled { if let Some(n) = handled {
@ -728,6 +729,17 @@ impl WindowsWindowInner {
fn handle_activate_msg(self: &Rc<Self>, wparam: WPARAM) -> Option<isize> { fn handle_activate_msg(self: &Rc<Self>, wparam: WPARAM) -> Option<isize> {
let activated = wparam.loword() > 0; let activated = wparam.loword() > 0;
let events = self
.state
.a11y
.try_borrow_mut()
.ok()
.and_then(|mut a11y| a11y.as_mut()?.adapter.update_window_focus_state(activated));
if let Some(events) = events {
events.raise();
}
let this = self.clone(); let this = self.clone();
if !activated { if !activated {
@ -764,6 +776,23 @@ impl WindowsWindowInner {
None None
} }
fn handle_wm_getobject(&self, wparam: WPARAM, lparam: LPARAM) -> Option<isize> {
let result = {
let mut a11y = self.state.a11y.borrow_mut();
let a11y = a11y.as_mut()?;
a11y.adapter.handle_wm_getobject(
accesskit_windows::WPARAM(wparam.0),
accesskit_windows::LPARAM(lparam.0),
&mut a11y.activation_handler,
)?
};
// The borrow above must be dropped before calling `.into()`, because
// it calls `UiaReturnRawElementProvider` which may send a nested
// `WM_GETOBJECT` back into this window procedure.
let lresult: accesskit_windows::LRESULT = result.into();
Some(lresult.0)
}
fn handle_create_msg(&self, handle: HWND) -> Option<isize> { fn handle_create_msg(&self, handle: HWND) -> Option<isize> {
if self.hide_title_bar { if self.hide_title_bar {
notify_frame_changed(handle); notify_frame_changed(handle);

View file

@ -83,6 +83,7 @@ pub struct WindowsWindowState {
fullscreen: Cell<Option<StyleAndBounds>>, fullscreen: Cell<Option<StyleAndBounds>>,
initial_placement: Cell<Option<WindowOpenStatus>>, initial_placement: Cell<Option<WindowOpenStatus>>,
hwnd: HWND, hwnd: HWND,
pub(crate) a11y: RefCell<Option<A11yState>>,
} }
pub(crate) struct WindowsWindowInner { pub(crate) struct WindowsWindowInner {
@ -176,6 +177,7 @@ impl WindowsWindowState {
hwnd, hwnd,
invalidate_devices, invalidate_devices,
direct_manipulation, direct_manipulation,
a11y: RefCell::new(None),
}) })
} }
@ -972,6 +974,69 @@ impl PlatformWindow for WindowsWindow {
// MB_OK: The sound specified as the Windows Default Beep sound. // MB_OK: The sound specified as the Windows Default Beep sound.
let _ = unsafe { MessageBeep(MB_OK) }; let _ = unsafe { MessageBeep(MB_OK) };
} }
fn a11y_init(&self, callbacks: gpui::A11yCallbacks) {
let action_handler = A11yActionHandler(callbacks.action);
let is_focused = unsafe { GetForegroundWindow() } == self.0.hwnd;
let adapter = accesskit_windows::Adapter::new(
accesskit_windows::HWND(self.0.hwnd.0),
is_focused,
action_handler,
);
let activation_handler = A11yActivationHandler {
callback: callbacks.activation,
};
*self.state.a11y.borrow_mut() = Some(A11yState {
adapter,
activation_handler,
});
}
fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) {
let events = {
let mut a11y = self.state.a11y.borrow_mut();
a11y.as_mut()
.and_then(|a11y| a11y.adapter.update_if_active(|| tree_update))
};
// The borrow must be dropped before raising events, because
// `events.raise()` calls `UiaRaiseAutomationPropertyChangedEvent`
// which may send a nested `WM_GETOBJECT` back into this window
// procedure, re-entering `handle_wm_getobject` which also borrows
// `self.state.a11y`.
if let Some(events) = events {
events.raise();
}
}
fn a11y_update_window_bounds(&self) {
// Windows UIA handles window bounds tracking automatically.
}
}
pub(crate) struct A11yState {
pub(crate) adapter: accesskit_windows::Adapter,
pub(crate) activation_handler: A11yActivationHandler,
}
pub(crate) struct A11yActivationHandler {
callback: Box<dyn Fn() -> Option<accesskit::TreeUpdate> + Send + 'static>,
}
impl accesskit::ActivationHandler for A11yActivationHandler {
fn request_initial_tree(&mut self) -> Option<accesskit::TreeUpdate> {
(self.callback)()
}
}
struct A11yActionHandler(Box<dyn Fn(accesskit::ActionRequest) + Send + 'static>);
impl accesskit::ActionHandler for A11yActionHandler {
fn do_action(&mut self, request: accesskit::ActionRequest) {
(self.0)(request);
}
} }
#[implement(IDropTarget)] #[implement(IDropTarget)]

View file

@ -184,7 +184,7 @@ impl Render for TitleBar {
let show_menus = show_menus(cx); let show_menus = show_menus(cx);
let mut children = <ArrayVec<_, 4>>::new(); let mut children = <ArrayVec<_, 5>>::new();
let mut project_name = None; let mut project_name = None;
let mut repository = None; let mut repository = None;
@ -238,6 +238,8 @@ impl Render for TitleBar {
} }
} }
children.push(gpui::text!("Hello from a11y").into_any_element());
children.push( children.push(
h_flex() h_flex()
.h_full() .h_full()

View file

@ -51,6 +51,15 @@
# we'll just put it on `$PATH`: # we'll just put it on `$PATH`:
nodejs_22 nodejs_22
zig zig
# A11y testing infra
gobject-introspection
at-spi2-core
(python3.withPackages (ps: [
ps.pyatspi
ps.pygobject3
]))
accerciser
]; ];
env = env =

View file

@ -1,7 +1,12 @@
{ inputs, ... }: { inputs, ... }:
{ {
perSystem = perSystem =
{ pkgs, ... }: {
pkgs,
lib,
system,
...
}:
let let
mkZed = import ../toolchain.nix { inherit inputs; }; mkZed = import ../toolchain.nix { inherit inputs; };
zed-editor = mkZed pkgs; zed-editor = mkZed pkgs;
@ -11,5 +16,10 @@
default = zed-editor; default = zed-editor;
debug = zed-editor.override { profile = "dev"; }; debug = zed-editor.override { profile = "dev"; };
}; };
}
// lib.optionalAttrs (lib.hasSuffix "linux" system) {
checks.a11y-test = import ../tests/a11y.nix {
inherit pkgs inputs;
};
}; };
} }

296
nix/tests/a11y.nix Normal file
View file

@ -0,0 +1,296 @@
# NixOS VM integration test for GPUI AccessKit (X11).
#
# Interactive use:
# nix run .#checks.x86_64-linux.a11y-test.driverInteractive
#
# Then in the Python REPL:
# start_all()
# machine.wait_for_x()
# machine.succeed("su - user -c 'DISPLAY=:0 gpui-a11y-example &'")
#
# Automated run:
# nix build .#checks.x86_64-linux.a11y-test
{
pkgs,
inputs,
}:
let
lib = pkgs.lib;
rustBin = inputs.rust-overlay.lib.mkRustBin { } pkgs;
rustToolchain = rustBin.fromRustupToolchainFile ../../rust-toolchain.toml;
craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustToolchain;
gpui-a11y-example =
let
src = builtins.path {
path = ../../.;
filter =
path: type:
let
root = toString ../../. + "/";
relPath = lib.removePrefix root path;
firstComp = builtins.head (lib.path.subpath.components relPath);
in
builtins.elem firstComp [
"crates"
"assets"
"extensions"
"script"
"tooling"
"Cargo.toml"
".config"
".cargo"
];
name = "gpui-a11y-source";
};
commonArgs = {
pname = "gpui-a11y-example";
version = "0.0.0";
inherit src;
cargoLock = ../../Cargo.lock;
cargoExtraArgs = "-p gpui --example a11y --locked --features=gpui_platform/runtime_shaders";
CARGO_PROFILE = "dev";
nativeBuildInputs = with pkgs; [
cmake
pkg-config
rustPlatform.bindgenHook
];
buildInputs = with pkgs; [
fontconfig
freetype
openssl
zlib
zstd
alsa-lib
libxkbcommon
wayland
vulkan-loader
libglvnd
libx11
libxcb
libdrm
libgbm
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
];
cargoVendorDir = craneLib.vendorCargoDeps {
inherit src;
cargoLock = ../../Cargo.lock;
};
env = {
ZSTD_SYS_USE_PKG_CONFIG = true;
FONTCONFIG_FILE = pkgs.makeFontsConf {
fontDirectories = [
../../assets/fonts/lilex
../../assets/fonts/ibm-plex-sans
];
};
};
doCheck = false;
stdenv =
let
base = pkgs.llvmPackages.stdenv;
addBinTools = old: {
cc = old.cc.override {
inherit (pkgs.llvmPackages) bintools;
};
};
in
lib.pipe base [
(s: s.override addBinTools)
pkgs.stdenvAdapters.useMoldLinker
];
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
in
craneLib.buildPackage (
lib.recursiveUpdate commonArgs {
inherit cargoArtifacts;
dontUseCmakeConfigure = true;
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cp target/debug/examples/a11y $out/bin/gpui-a11y-example
runHook postInstall
'';
NIX_LDFLAGS = "-rpath ${
lib.makeLibraryPath [
pkgs.vulkan-loader
pkgs.wayland
]
}";
dontPatchELF = true;
meta = {
description = "GPUI accessibility (AccessKit) example app";
platforms = lib.platforms.linux;
};
}
);
atspiTestScript = pkgs.writeTextFile {
name = "a11y-atspi-test";
text = builtins.readFile ./a11y_atspi_test.py;
destination = "/bin/a11y-atspi-test";
executable = true;
checkPhase = ''
${pkgs.python3.interpreter} -m py_compile $target
'';
};
testPython = pkgs.python3.withPackages (ps: [ ps.pyatspi ps.pygobject3 ]);
giTypelibPath = lib.makeSearchPath "lib/girepository-1.0" [
pkgs.at-spi2-core
pkgs.glib
pkgs.gtk3
pkgs.gobject-introspection
];
in
pkgs.testers.nixosTest {
name = "gpui-a11y-x11";
nodes.machine =
{ pkgs, ... }:
{
imports = [ ];
# Minimal X11 desktop
services.xserver = {
enable = true;
desktopManager.xfce.enable = true;
displayManager.lightdm.enable = true;
};
# Auto-login so the test doesn't need to type a password
services.displayManager.autoLogin = {
enable = true;
user = "user";
};
# AT-SPI2 accessibility bus
services.gnome.at-spi2-core.enable = true;
# dconf + GSettings schemas required for Orca / AT-SPI
programs.dconf = {
enable = true;
profiles.user.databases = [
{
settings = {
"org/gnome/desktop/interface".toolkit-accessibility = true;
"org/gnome/desktop/a11y/applications".screen-reader-enabled = true;
};
}
];
};
# Environment variables for debugging
environment.variables = {
RUST_BACKTRACE = "1";
};
# Start Orca automatically on login
systemd.user.services.orca = {
description = "Orca screen reader";
wantedBy = [ "graphical-session.target" ];
partOf = [ "graphical-session.target" ];
after = [ "graphical-session.target" ];
serviceConfig = {
ExecStart = "${pkgs.orca}/bin/orca --debug";
Restart = "on-failure";
};
environment = {
DISPLAY = ":0";
};
};
# Accessibility tools available in the VM
environment.systemPackages = [
gpui-a11y-example
atspiTestScript
testPython
pkgs.accerciser
pkgs.gsettings-desktop-schemas
pkgs.orca
pkgs.xdotool
];
# Test user
users.users.user = {
isNormalUser = true;
password = "pass";
extraGroups = [ "wheel" ];
};
# Give the VM enough resources for a GUI
virtualisation = {
memorySize = 4096;
cores = 2;
qemu.options = [
"-vga virtio"
];
};
};
testScript = ''
machine.wait_for_x()
machine.wait_for_unit("graphical.target")
# Let the desktop and Orca settle
machine.sleep(5)
# Launch the a11y example, capturing logs to a file
machine.succeed(
"su - user -c 'DISPLAY=:0 WAYLAND_DISPLAY= RUST_LOG=gpui=info gpui-a11y-example > /tmp/gpui.log 2>&1 &'"
)
# Wait for the window to appear
machine.wait_until_succeeds("su - user -c 'DISPLAY=:0 xdotool search --name \"GPUI Accessibility Demo\"'", timeout=15)
# Wait for accessibility activation
machine.wait_until_succeeds("grep -q 'Accessibility activated' /tmp/gpui.log", timeout=15)
machine.log("Accessibility activation confirmed in logs")
# Give AccessKit time to register on AT-SPI
machine.sleep(3)
# Run the AT-SPI test script
machine.succeed(
"su - user -c 'DISPLAY=:0 GI_TYPELIB_PATH=${giTypelibPath} ${testPython}/bin/python3 ${atspiTestScript}/bin/a11y-atspi-test'"
)
machine.log("AT-SPI tests passed (first run)")
# Kill the app, restart Orca, and re-run
machine.execute("pkill -f gpui-a11y-example")
machine.sleep(1)
machine.succeed("su - user -c 'XDG_RUNTIME_DIR=/run/user/1000 systemctl --user restart orca'")
machine.sleep(3)
# Relaunch the app
machine.succeed(
"su - user -c 'DISPLAY=:0 WAYLAND_DISPLAY= RUST_LOG=gpui=info gpui-a11y-example > /tmp/gpui2.log 2>&1 &'"
)
machine.wait_until_succeeds("su - user -c 'DISPLAY=:0 xdotool search --name \"GPUI Accessibility Demo\"'", timeout=15)
machine.wait_until_succeeds("grep -q 'Accessibility activated' /tmp/gpui2.log", timeout=15)
machine.log("Accessibility activation confirmed after Orca restart")
machine.sleep(3)
# Run the AT-SPI test script again
machine.succeed(
"su - user -c 'DISPLAY=:0 GI_TYPELIB_PATH=${giTypelibPath} ${testPython}/bin/python3 ${atspiTestScript}/bin/a11y-atspi-test'"
)
machine.log("AT-SPI tests passed (second run, after Orca restart)")
'';
}

View file

@ -0,0 +1,205 @@
"""AT-SPI integration test for the GPUI a11y example app.
Walks the AT-SPI tree, finds the GPUI app, and exercises the counter
(spin button with increment/decrement), reset button, and toggle switch
asserting accessible state after each interaction.
"""
import sys
import time
import pyatspi
def find_app():
"""Find the GPUI a11y example in the AT-SPI desktop."""
desktop = pyatspi.Registry.getDesktop(0)
for app in desktop:
if "gpui" in app.name.lower() or "a11y" in app.name.lower():
return app
names = [a.name for a in desktop]
raise AssertionError(f"GPUI app not found in AT-SPI desktop. Apps: {names}")
def find_by_role_and_label(root, role, label_substring):
"""Depth-first search for a node matching role and label substring."""
for child in root:
if child.getRole() == role and label_substring in (child.name or ""):
return child
result = find_by_role_and_label(child, role, label_substring)
if result is not None:
return result
return None
def find_by_role(root, role):
"""Depth-first search for all nodes matching role."""
results = []
for child in root:
if child.getRole() == role:
results.append(child)
results.extend(find_by_role(child, role))
return results
def do_action_by_name(node, action_name):
"""Perform a named action on a node."""
actions = node.queryAction()
for i in range(actions.nActions):
if actions.getName(i).lower() == action_name.lower():
actions.doAction(i)
time.sleep(0.5)
return
available = [actions.getName(i) for i in range(actions.nActions)]
raise AssertionError(
f"No '{action_name}' action on node: {node.name} "
f"(role={node.getRoleName()}). Available: {available}"
)
def click(node):
"""Perform the Click action on a node."""
do_action_by_name(node, "click")
def get_toggled_state(node):
"""Return whether the node is in a 'checked'/'pressed' state."""
state_set = node.getState()
return state_set.contains(pyatspi.STATE_CHECKED) or state_set.contains(pyatspi.STATE_PRESSED)
def get_counter(app):
counter = find_by_role_and_label(app, pyatspi.ROLE_SPIN_BUTTON, "Counter:")
assert counter is not None, "Counter (spin button) not found"
return counter
def get_reset_button(app):
button = find_by_role_and_label(app, pyatspi.ROLE_PUSH_BUTTON, "Reset counter")
assert button is not None, "Reset button not found"
return button
def get_toggle_switch(app):
switches = find_by_role(app, pyatspi.ROLE_TOGGLE_BUTTON)
if not switches:
raise AssertionError(
f"No toggle switch found. Roles present: "
f"{[(c.getRoleName(), c.name) for c in find_by_role(app, None) if True]}"
)
switch = None
for s in switches:
if "feature" in (s.name or "").lower() or "enable" in (s.name or "").lower():
switch = s
break
if switch is None:
switch = switches[0]
return switch
def assert_count(app, expected):
"""Assert the counter's label contains the expected count."""
counter = get_counter(app)
expected_str = f"Counter: {expected}"
assert expected_str in counter.name, (
f"Expected label to contain '{expected_str}', got: '{counter.name}'"
)
print(f" OK: count is {expected}")
def get_numeric_value(node):
"""Get the current numeric value from the AT-SPI Value interface."""
value = node.queryValue()
return value.currentValue
def run_tests():
print("Finding GPUI app in AT-SPI tree...")
app = find_app()
print(f"Found app: {app.name}")
# --- Counter (spin button) ---
print("\n--- Counter spin button tests ---")
print("Checking initial count is 0...")
assert_count(app, 0)
# Verify the Value interface reports 0
counter = get_counter(app)
val = get_numeric_value(counter)
print(f" Value interface reports: {val}")
assert val == 0.0, f"Expected numeric value 0.0, got {val}"
print(" OK: numeric value is 0")
# Test click (increments)
for i in range(1, 4):
print(f"Clicking counter (expecting {i})...")
counter = get_counter(app)
click(counter)
assert_count(app, i)
# Verify the Value interface tracks the count
counter = get_counter(app)
val = get_numeric_value(counter)
assert val == 3.0, f"Expected numeric value 3.0, got {val}"
print(" OK: numeric value is 3 after 3 clicks")
# List available actions for diagnostics
counter = get_counter(app)
actions = counter.queryAction()
available = [actions.getName(i) for i in range(actions.nActions)]
print(f" Available actions on counter: {available}")
# Test reset button
print("Clicking reset...")
reset = get_reset_button(app)
click(reset)
assert_count(app, 0)
# --- Toggle switch ---
print("\n--- Toggle switch tests ---")
switch = get_toggle_switch(app)
print(f"Switch: role={switch.getRoleName()}, name={switch.name}")
toggled = get_toggled_state(switch)
print(f"Initial toggle state: {toggled}")
assert not toggled, f"Expected switch to be OFF initially, got {toggled}"
print(" OK: switch is OFF")
print("Toggling switch ON...")
click(switch)
switch = get_toggle_switch(app)
toggled = get_toggled_state(switch)
assert toggled, f"Expected switch to be ON after toggle, got {toggled}"
print(" OK: switch is ON")
print("Toggling switch OFF...")
click(switch)
switch = get_toggle_switch(app)
toggled = get_toggled_state(switch)
assert not toggled, f"Expected switch to be OFF after second toggle, got {toggled}"
print(" OK: switch is OFF")
# --- Window bounds / Component extents ---
print("\n--- Component extents tests ---")
counter = get_counter(app)
component = counter.queryComponent()
extents = component.getExtents(pyatspi.DESKTOP_COORDS)
print(f" Counter extents (desktop coords): x={extents.x}, y={extents.y}, "
f"width={extents.width}, height={extents.height}")
assert extents.width > 0 and extents.height > 0, (
f"Expected non-zero extents from Component interface, got {extents}. "
f"This likely means a11y_update_window_bounds is not reporting bounds."
)
print(" OK: counter has non-zero extents")
print("\n=== ALL TESTS PASSED ===")
if __name__ == "__main__":
try:
run_tests()
except Exception as e:
print(f"\nFAILED: {e}", file=sys.stderr)
sys.exit(1)