mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
bf4e559347
commit
1d029c5ff5
27 changed files with 2738 additions and 58 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -55,3 +55,6 @@ crates/docs_preprocessor/actions.json
|
|||
# Local documentation audit files
|
||||
/december-2025-releases.md
|
||||
/docs/december-2025-documentation-gaps.md
|
||||
|
||||
# NixOS integration test state
|
||||
.nixos-test-history
|
||||
|
|
|
|||
388
Cargo.lock
generated
388
Cargo.lock
generated
|
|
@ -2,6 +2,85 @@
|
|||
# It is not intended for manual editing.
|
||||
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]]
|
||||
name = "acp_thread"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1235,6 +1314,43 @@ version = "1.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "audio"
|
||||
version = "0.1.0"
|
||||
|
|
@ -2183,13 +2299,22 @@ dependencies = [
|
|||
"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]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3066,7 +3191,7 @@ dependencies = [
|
|||
"http_client_tls",
|
||||
"httparse",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"paths",
|
||||
"postage",
|
||||
|
|
@ -4002,13 +4127,13 @@ dependencies = [
|
|||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-avf-audio",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
|
|
@ -5209,9 +5334,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7964,6 +8089,7 @@ dependencies = [
|
|||
name = "gpui"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"anyhow",
|
||||
"async-channel 2.5.0",
|
||||
"async-task",
|
||||
|
|
@ -8007,8 +8133,8 @@ dependencies = [
|
|||
"metal",
|
||||
"num_cpus",
|
||||
"objc",
|
||||
"objc2",
|
||||
"objc2-metal",
|
||||
"objc2 0.6.3",
|
||||
"objc2-metal 0.3.2",
|
||||
"parking",
|
||||
"parking_lot",
|
||||
"pathfinder_geometry",
|
||||
|
|
@ -8054,6 +8180,8 @@ dependencies = [
|
|||
name = "gpui_linux"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_unix",
|
||||
"anyhow",
|
||||
"as-raw-xcb-connection",
|
||||
"ashpd",
|
||||
|
|
@ -8102,6 +8230,8 @@ dependencies = [
|
|||
name = "gpui_macos"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_macos",
|
||||
"anyhow",
|
||||
"async-task",
|
||||
"block",
|
||||
|
|
@ -8128,7 +8258,7 @@ dependencies = [
|
|||
"media",
|
||||
"metal",
|
||||
"objc",
|
||||
"objc2-app-kit",
|
||||
"objc2-app-kit 0.3.1",
|
||||
"parking_lot",
|
||||
"pathfinder_geometry",
|
||||
"raw-window-handle",
|
||||
|
|
@ -8246,6 +8376,8 @@ dependencies = [
|
|||
name = "gpui_windows"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_windows",
|
||||
"anyhow",
|
||||
"collections",
|
||||
"etagere",
|
||||
|
|
@ -12076,6 +12208,22 @@ dependencies = [
|
|||
"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]]
|
||||
name = "objc2"
|
||||
version = "0.6.3"
|
||||
|
|
@ -12085,14 +12233,30 @@ dependencies = [
|
|||
"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]]
|
||||
name = "objc2-app-kit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2 0.6.3",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -12103,11 +12267,11 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
|||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -12116,8 +12280,8 @@ version = "0.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2 0.6.3",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -12127,10 +12291,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -12140,7 +12304,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||
dependencies = [
|
||||
"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]]
|
||||
|
|
@ -12150,10 +12326,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"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]]
|
||||
|
|
@ -12162,6 +12350,18 @@ version = "4.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "objc2-foundation"
|
||||
version = "0.3.2"
|
||||
|
|
@ -12169,9 +12369,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
|
|
@ -12185,6 +12385,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "objc2-metal"
|
||||
version = "0.3.2"
|
||||
|
|
@ -12192,11 +12404,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
"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]]
|
||||
|
|
@ -12206,10 +12431,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-metal",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-metal 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -14642,6 +14867,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
|
|
@ -14953,10 +15188,10 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -19204,7 +19439,7 @@ dependencies = [
|
|||
"toml_datetime 0.7.3",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
"winnow 0.7.13",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -19236,7 +19471,7 @@ dependencies = [
|
|||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
"winnow 0.7.13",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -19248,7 +19483,7 @@ dependencies = [
|
|||
"indexmap 2.11.4",
|
||||
"toml_datetime 0.7.3",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 0.7.13",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -19257,7 +19492,7 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 0.7.13",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -19506,8 +19741,8 @@ dependencies = [
|
|||
"chrono",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2 0.6.3",
|
||||
"objc2-foundation 0.3.2",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"scopeguard",
|
||||
|
|
@ -21404,7 +21639,7 @@ dependencies = [
|
|||
"ash",
|
||||
"bit-set 0.9.1",
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.2.1",
|
||||
|
|
@ -21420,11 +21655,11 @@ dependencies = [
|
|||
"log",
|
||||
"naga",
|
||||
"ndk-sys",
|
||||
"objc2",
|
||||
"objc2 0.6.3",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-metal",
|
||||
"objc2-quartz-core",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-metal 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"once_cell",
|
||||
"ordered-float 4.6.0",
|
||||
"parking_lot",
|
||||
|
|
@ -22359,6 +22594,15 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
|
|
@ -23179,12 +23423,36 @@ dependencies = [
|
|||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow",
|
||||
"winnow 0.7.13",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"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]]
|
||||
name = "zbus_macros"
|
||||
version = "5.13.2"
|
||||
|
|
@ -23202,12 +23470,24 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.1"
|
||||
version = "4.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
||||
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
|
||||
dependencies = [
|
||||
"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",
|
||||
]
|
||||
|
||||
|
|
@ -23859,24 +24139,24 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.9.2"
|
||||
version = "5.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4"
|
||||
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"winnow",
|
||||
"winnow 1.0.2",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.9.2"
|
||||
version = "5.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c"
|
||||
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
|
|
@ -23887,13 +24167,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.3.0"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
|
||||
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.117",
|
||||
"winnow",
|
||||
"winnow 1.0.2",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -503,6 +503,10 @@ ztracing_macro = { path = "crates/ztracing_macro" }
|
|||
# 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"] }
|
||||
aho-corasick = "1.1"
|
||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" }
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ path = "src/gpui.rs"
|
|||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
accesskit.workspace = true
|
||||
anyhow.workspace = true
|
||||
async-task = "4.7"
|
||||
backtrace = { workspace = true, optional = true }
|
||||
|
|
@ -175,6 +176,8 @@ cbindgen = { version = "0.28.0", default-features = false }
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
[[example]]
|
||||
name = "hello_world"
|
||||
path = "examples/hello_world.rs"
|
||||
|
|
@ -250,3 +253,7 @@ path = "examples/list_example.rs"
|
|||
[[example]]
|
||||
name = "mouse_pressure"
|
||||
path = "examples/mouse_pressure.rs"
|
||||
|
||||
[[example]]
|
||||
name = "a11y"
|
||||
path = "examples/a11y.rs"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ gpui = { version = "*" }
|
|||
```
|
||||
|
||||
- [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.
|
||||
|
||||
|
|
|
|||
264
crates/gpui/examples/a11y.rs
Normal file
264
crates/gpui/examples/a11y.rs
Normal 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();
|
||||
}
|
||||
243
crates/gpui/src/_accessibility.rs
Normal file
243
crates/gpui/src/_accessibility.rs
Normal 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 :)
|
||||
|
|
@ -103,6 +103,22 @@ pub trait Element: 'static + IntoElement {
|
|||
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`].
|
||||
fn into_any(self) -> AnyElement {
|
||||
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 {
|
||||
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 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 prepaint = self.element.prepaint(
|
||||
global_id.as_ref(),
|
||||
|
|
@ -442,6 +487,10 @@ impl<E: Element> Drawable<E> {
|
|||
);
|
||||
window.next_frame.dispatch_tree.pop_node();
|
||||
|
||||
if pushed_a11y_node {
|
||||
window.a11y.nodes.pop();
|
||||
}
|
||||
|
||||
if global_id.is_some() {
|
||||
window.element_id_stack.pop();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1175,6 +1175,124 @@ pub trait InteractiveElement: Sized {
|
|||
/// A trait for elements that want to use the standard GPUI interactivity features
|
||||
/// that require state.
|
||||
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.
|
||||
fn focusable(mut self) -> Self {
|
||||
self.interactivity().focusable = true;
|
||||
|
|
@ -1474,6 +1592,18 @@ impl Element for Div {
|
|||
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]
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
|
|
@ -1710,6 +1840,25 @@ pub struct Interactivity {
|
|||
pub(crate) tab_group: 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))]
|
||||
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() {
|
||||
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, _>(
|
||||
global_id,
|
||||
|
|
@ -2054,6 +2213,22 @@ impl Interactivity {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
if let Some(_hitbox) = hitbox {
|
||||
|
|
@ -2857,6 +3032,63 @@ impl Interactivity {
|
|||
|
||||
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
|
||||
|
|
@ -3263,6 +3495,14 @@ where
|
|||
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(
|
||||
&mut self,
|
||||
id: Option<&GlobalElementId>,
|
||||
|
|
|
|||
|
|
@ -13,11 +13,244 @@ use std::{
|
|||
borrow::Cow,
|
||||
cell::{Cell, RefCell},
|
||||
mem,
|
||||
ops::Range,
|
||||
ops::{Deref, DerefMut, Range},
|
||||
rc::Rc,
|
||||
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 {
|
||||
type RequestLayoutState = TextLayout;
|
||||
type PrepaintState = ();
|
||||
|
|
@ -807,6 +1040,14 @@ impl Element for InteractiveText {
|
|||
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(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
|
|
@ -1009,6 +1250,8 @@ impl IntoElement for InteractiveText {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_into_element_for() {
|
||||
use crate::{ParentElement as _, SharedString, div};
|
||||
|
|
@ -1019,4 +1262,23 @@ mod tests {
|
|||
let _ = div().child(Cow::Borrowed("Cow"));
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ mod window;
|
|||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use proptest;
|
||||
|
||||
#[cfg(doc)]
|
||||
pub mod _accessibility;
|
||||
#[cfg(doc)]
|
||||
pub mod _ownership_and_data_flow;
|
||||
|
||||
|
|
@ -75,6 +77,9 @@ mod seal {
|
|||
pub trait Sealed {}
|
||||
}
|
||||
|
||||
pub use accesskit;
|
||||
pub use accesskit::Action as AccessibleAction;
|
||||
pub use accesskit::{Orientation, Role, Toggled};
|
||||
pub use action::*;
|
||||
pub use anyhow::Result;
|
||||
pub use app::*;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
#[expect(missing_docs)]
|
||||
pub struct RequestFrameOptions {
|
||||
|
|
@ -700,6 +710,15 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
|
|||
|
||||
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"))]
|
||||
fn as_test(&mut self) -> Option<&mut TestWindow> {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -52,14 +52,18 @@ use std::{
|
|||
rc::Rc,
|
||||
sync::{
|
||||
Arc, Weak,
|
||||
atomic::{AtomicUsize, Ordering::SeqCst},
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) mod a11y;
|
||||
mod prompts;
|
||||
|
||||
use self::a11y::A11y;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use self::a11y::ROOT_NODE_ID;
|
||||
use crate::util::{
|
||||
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,
|
||||
|
|
@ -1021,6 +1025,7 @@ pub struct Window {
|
|||
captured_hitbox: Option<HitboxId>,
|
||||
#[cfg(any(feature = "inspector", debug_assertions))]
|
||||
inspector: Option<Entity<Inspector>>,
|
||||
pub(crate) a11y: A11y,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -1325,6 +1330,85 @@ impl Window {
|
|||
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({
|
||||
let window_id = handle.window_id();
|
||||
let mut cx = cx.to_async();
|
||||
|
|
@ -1633,6 +1717,7 @@ impl Window {
|
|||
captured_hitbox: None,
|
||||
#[cfg(any(feature = "inspector", debug_assertions))]
|
||||
inspector: None,
|
||||
a11y: A11y::new(a11y_active_flag),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -2620,6 +2705,11 @@ impl Window {
|
|||
self.invalidator.set_phase(DrawPhase::Prepaint);
|
||||
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 root_size = {
|
||||
#[cfg(any(feature = "inspector", debug_assertions))]
|
||||
|
|
@ -2686,6 +2776,26 @@ impl Window {
|
|||
|
||||
#[cfg(any(feature = "inspector", debug_assertions))]
|
||||
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> {
|
||||
|
|
@ -5296,6 +5406,87 @@ impl Window {
|
|||
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.
|
||||
#[cfg(any(feature = "inspector", debug_assertions))]
|
||||
pub fn toggle_inspector(&mut self, cx: &mut App) {
|
||||
|
|
|
|||
281
crates/gpui/src/window/a11y.rs
Normal file
281
crates/gpui/src/window/a11y.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,8 @@ screen-capture = [
|
|||
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
accesskit.workspace = true
|
||||
accesskit_unix.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytemuck = "1"
|
||||
collections.workspace = true
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ pub struct WaylandWindowState {
|
|||
in_progress_window_controls: Option<WindowControls>,
|
||||
window_controls: WindowControls,
|
||||
client_inset: Option<Pixels>,
|
||||
accesskit_adapter: Option<accesskit_unix::Adapter>,
|
||||
}
|
||||
|
||||
pub enum WaylandSurfaceState {
|
||||
|
|
@ -398,6 +399,7 @@ impl WaylandWindowState {
|
|||
in_progress_window_controls: None,
|
||||
window_controls: WindowControls::default(),
|
||||
client_inset: None,
|
||||
accesskit_adapter: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1047,6 +1049,9 @@ impl WaylandWindowStatePtr {
|
|||
fun(focus);
|
||||
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) {
|
||||
|
|
@ -1519,6 +1524,60 @@ impl PlatformWindow for WaylandWindow {
|
|||
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>) {
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ pub struct X11WindowState {
|
|||
edge_constraints: Option<EdgeConstraints>,
|
||||
pub handle: AnyWindowHandle,
|
||||
last_insets: [u32; 4],
|
||||
accesskit_adapter: Option<accesskit_unix::Adapter>,
|
||||
}
|
||||
|
||||
impl X11WindowState {
|
||||
|
|
@ -801,6 +802,7 @@ impl X11WindowState {
|
|||
decorations: WindowDecorations::Server,
|
||||
last_insets: [0, 0, 0, 0],
|
||||
edge_constraints: None,
|
||||
accesskit_adapter: None,
|
||||
counter_id: sync_request_counter,
|
||||
last_sync_counter: None,
|
||||
})
|
||||
|
|
@ -1277,6 +1279,9 @@ impl X11WindowStatePtr {
|
|||
fun(focus);
|
||||
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) {
|
||||
|
|
@ -1886,4 +1891,84 @@ impl PlatformWindow for X11Window {
|
|||
// Volume 0% means don't increase or decrease from system volume
|
||||
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)();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ screen-capture = ["gpui/screen-capture"]
|
|||
gpui.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
accesskit.workspace = true
|
||||
accesskit_macos.workspace = true
|
||||
anyhow.workspace = true
|
||||
async-task = "4.7"
|
||||
block = "0.1"
|
||||
|
|
|
|||
|
|
@ -500,6 +500,7 @@ struct MacWindowState {
|
|||
toggle_tab_bar_callback: Option<Box<dyn FnMut()>>,
|
||||
activated_least_once: bool,
|
||||
closed: Arc<AtomicBool>,
|
||||
accesskit_adapter: Option<accesskit_macos::SubclassingAdapter>,
|
||||
// The parent window if this window is a sheet (Dialog kind)
|
||||
sheet_parent: Option<id>,
|
||||
}
|
||||
|
|
@ -829,6 +830,7 @@ impl MacWindow {
|
|||
toggle_tab_bar_callback: None,
|
||||
activated_least_once: false,
|
||||
closed: Arc::new(AtomicBool::new(false)),
|
||||
accesskit_adapter: None,
|
||||
sheet_parent: None,
|
||||
})));
|
||||
|
||||
|
|
@ -1730,6 +1732,59 @@ impl PlatformWindow for MacWindow {
|
|||
let mut this = self.0.lock();
|
||||
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 {
|
||||
|
|
@ -2341,6 +2396,16 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id)
|
|||
let executor = lock.foreground_executor.clone();
|
||||
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
|
||||
// tab flicker when switching between windows in native tabs mode.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ screen-capture = ["gpui/screen-capture", "scap"]
|
|||
gpui.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
accesskit.workspace = true
|
||||
accesskit_windows.workspace = true
|
||||
anyhow.workspace = true
|
||||
collections.workspace = true
|
||||
etagere = "0.2"
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ impl WindowsWindowInner {
|
|||
WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true),
|
||||
WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam),
|
||||
DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam),
|
||||
WM_GETOBJECT => self.handle_wm_getobject(wparam, lparam),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(n) = handled {
|
||||
|
|
@ -728,6 +729,17 @@ impl WindowsWindowInner {
|
|||
|
||||
fn handle_activate_msg(self: &Rc<Self>, wparam: WPARAM) -> Option<isize> {
|
||||
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();
|
||||
|
||||
if !activated {
|
||||
|
|
@ -764,6 +776,23 @@ impl WindowsWindowInner {
|
|||
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> {
|
||||
if self.hide_title_bar {
|
||||
notify_frame_changed(handle);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ pub struct WindowsWindowState {
|
|||
fullscreen: Cell<Option<StyleAndBounds>>,
|
||||
initial_placement: Cell<Option<WindowOpenStatus>>,
|
||||
hwnd: HWND,
|
||||
pub(crate) a11y: RefCell<Option<A11yState>>,
|
||||
}
|
||||
|
||||
pub(crate) struct WindowsWindowInner {
|
||||
|
|
@ -176,6 +177,7 @@ impl WindowsWindowState {
|
|||
hwnd,
|
||||
invalidate_devices,
|
||||
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.
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ impl Render for TitleBar {
|
|||
|
||||
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 repository = None;
|
||||
|
|
@ -238,6 +238,8 @@ impl Render for TitleBar {
|
|||
}
|
||||
}
|
||||
|
||||
children.push(gpui::text!("Hello from a11y").into_any_element());
|
||||
|
||||
children.push(
|
||||
h_flex()
|
||||
.h_full()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,15 @@
|
|||
# we'll just put it on `$PATH`:
|
||||
nodejs_22
|
||||
zig
|
||||
|
||||
# A11y testing infra
|
||||
gobject-introspection
|
||||
at-spi2-core
|
||||
(python3.withPackages (ps: [
|
||||
ps.pyatspi
|
||||
ps.pygobject3
|
||||
]))
|
||||
accerciser
|
||||
];
|
||||
|
||||
env =
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
{ inputs, ... }:
|
||||
{
|
||||
perSystem =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
system,
|
||||
...
|
||||
}:
|
||||
let
|
||||
mkZed = import ../toolchain.nix { inherit inputs; };
|
||||
zed-editor = mkZed pkgs;
|
||||
|
|
@ -11,5 +16,10 @@
|
|||
default = zed-editor;
|
||||
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
296
nix/tests/a11y.nix
Normal 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)")
|
||||
'';
|
||||
}
|
||||
205
nix/tests/a11y_atspi_test.py
Normal file
205
nix/tests/a11y_atspi_test.py
Normal 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)
|
||||
Loading…
Reference in a new issue