diff --git a/.gitignore b/.gitignore index becb768d27..cf75babbcd 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 18e1162b1b..8398ff00bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 4441be0383..05f551db00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index a2f4bf8127..64b568a22f 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -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" diff --git a/crates/gpui/README.md b/crates/gpui/README.md index e7950de23a..6d4a37932f 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -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. diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs new file mode 100644 index 0000000000..c406f4d85c --- /dev/null +++ b/crates/gpui/examples/a11y.rs @@ -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: ", where +//! `` 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 { + 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) -> 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(); +} diff --git a/crates/gpui/src/_accessibility.rs b/crates/gpui/src/_accessibility.rs new file mode 100644 index 0000000000..155dd6506e --- /dev/null +++ b/crates/gpui/src/_accessibility.rs @@ -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 :) diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index 7b78e9b0ad..e05d6e97f4 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -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 { + 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 Drawable { } 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 Drawable { ); 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(); } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 44a1bc4b38..6e28088356 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -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) -> 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 { + // 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, + pub(crate) aria_label: Option, + pub(crate) aria_selected: Option, + pub(crate) aria_expanded: Option, + pub(crate) aria_toggled: Option, + pub(crate) aria_numeric_value: Option, + pub(crate) aria_min_numeric_value: Option, + pub(crate) aria_max_numeric_value: Option, + pub(crate) aria_orientation: Option, + pub(crate) aria_level: Option, + pub(crate) aria_position_in_set: Option, + pub(crate) aria_size_of_set: Option, + pub(crate) aria_row_index: Option, + pub(crate) aria_column_index: Option, + pub(crate) aria_row_count: Option, + pub(crate) aria_column_count: Option, + #[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::( 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 { + 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>, diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 7c268c8b19..82d23c83b7 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -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, + 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) -> 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 { + self.id.clone() + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn a11y_role(&self) -> Option { + 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) { + ::request_layout(&mut self.text, id, inspector_id, window, cx) + } + + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + ::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, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + ::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 { + 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 + ); + } } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 5f1e9a95bc..12aeebfb95 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -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::*; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 00cd9d13a4..fe93222a50 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -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 Option + Send + 'static>, + /// Called when an action is requested by the screen reader. + pub action: Box, + /// Called when the adapter is deactivated (screen reader disconnects). + pub deactivation: Box, +} + #[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 diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 61257ea404..9310679231 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -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, #[cfg(any(feature = "inspector", debug_assertions))] inspector: Option>, + 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::(); + + 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 { @@ -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) { diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs new file mode 100644 index 0000000000..2e0e57a7dd --- /dev/null +++ b/crates/gpui/src/window/a11y.rs @@ -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`). +//! +//! 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, &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, + /// 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, + pub(crate) node_bounds: FxHashMap>, + pub(crate) action_listeners: FxHashMap>, +} + +impl A11y { + pub(crate) fn new(active_flag: Arc) -> 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` to remove the need for `seen_ids` + all_nodes: Vec<(NodeId, accesskit::Node)>, + seen_ids: FxHashSet, + 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, + } + } +} diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index a088a5205f..0e9de0ebc0 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -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 diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 73a2bda279..d8cf35686e 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -123,6 +123,7 @@ pub struct WaylandWindowState { in_progress_window_controls: Option, window_controls: WindowControls, client_inset: Option, + accesskit_adapter: Option, } 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 Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct TrivialActionHandler(Box); + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + +struct TrivialDeactivationHandler { + callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } } fn update_window(mut state: RefMut) { diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 0e402a7d63..1f3ca51155 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -285,6 +285,7 @@ pub struct X11WindowState { edge_constraints: Option, pub handle: AnyWindowHandle, last_insets: [u32; 4], + accesskit_adapter: Option, } 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 Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct TrivialActionHandler(Box); + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + +struct TrivialDeactivationHandler { + callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } } diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 84bde263f2..2c9446401b 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -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" diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index daf4de8fb6..68cb31d9ea 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -500,6 +500,7 @@ struct MacWindowState { toggle_tab_bar_callback: Option>, activated_least_once: bool, closed: Arc, + accesskit_adapter: Option, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, } @@ -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 Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +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. // diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index 992a47dce9..f5a93e7f5a 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -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" diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 77c4cde978..b04b819a02 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -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, wparam: WPARAM) -> Option { 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 { + 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 { if self.hide_title_bar { notify_frame_changed(handle); diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 178d750024..b33d8702f2 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -83,6 +83,7 @@ pub struct WindowsWindowState { fullscreen: Cell>, initial_placement: Cell>, hwnd: HWND, + pub(crate) a11y: RefCell>, } 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 Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } } #[implement(IDropTarget)] diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index b2f2b4f436..dea3e066ae 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -184,7 +184,7 @@ impl Render for TitleBar { let show_menus = show_menus(cx); - let mut children = >::new(); + let mut children = >::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() diff --git a/nix/modules/devshells.nix b/nix/modules/devshells.nix index ab58d37fff..d898d62541 100644 --- a/nix/modules/devshells.nix +++ b/nix/modules/devshells.nix @@ -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 = diff --git a/nix/modules/packages.nix b/nix/modules/packages.nix index 1d2228c8cf..9c6cf5e10c 100644 --- a/nix/modules/packages.nix +++ b/nix/modules/packages.nix @@ -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; + }; }; } diff --git a/nix/tests/a11y.nix b/nix/tests/a11y.nix new file mode 100644 index 0000000000..f16deac866 --- /dev/null +++ b/nix/tests/a11y.nix @@ -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)") + ''; +} diff --git a/nix/tests/a11y_atspi_test.py b/nix/tests/a11y_atspi_test.py new file mode 100644 index 0000000000..b9caf59a1d --- /dev/null +++ b/nix/tests/a11y_atspi_test.py @@ -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)