From e99e701ea01aa403594f7ec710ce7ac4a6ff6722 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Sat, 26 Sep 2026 15:48:20 -0400 Subject: [PATCH] shikra: Add AccessKit support --- Cargo.lock | 643 ++++++++++++++++++++++ crates/shikra/Cargo.toml | 3 + crates/shikra/examples/input.rs | 12 +- crates/shikra/src/div.rs | 51 +- crates/shikra/src/element.rs | 4 + crates/shikra/src/lib.rs | 1 + crates/shikra/src/platform.rs | 77 ++- crates/shikra/src/text.rs | 16 +- crates/shikra/src/window.rs | 10 + crates/shikra/src/window/accessibility.rs | 381 +++++++++++++ crates/shikra/src/window/dispatch.rs | 94 +++- 11 files changed, 1272 insertions(+), 20 deletions(-) create mode 100644 crates/shikra/src/window/accessibility.rs diff --git a/Cargo.lock b/Cargo.lock index 4961b59..25a049f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,112 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "accesskit" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" +dependencies = [ + "accesskit", + "hashbrown 0.17.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", +] + +[[package]] +name = "accesskit_macos" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "atspi", + "futures-lite", + "serde", + "tokio", + "tokio-stream", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "ahash" version = "0.8.12" @@ -104,18 +210,180 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-task" version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "atomic-waker" 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 = "autocfg" version = "1.5.1" @@ -219,6 +487,19 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -446,6 +727,33 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -480,6 +788,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -589,6 +917,25 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-task" version = "0.3.34" @@ -738,6 +1085,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.5.0" @@ -1198,6 +1551,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1673,6 +2035,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -1682,6 +2054,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1750,6 +2128,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -1776,6 +2197,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2169,6 +2601,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2185,6 +2628,8 @@ dependencies = [ name = "shikra" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_winit", "anyhow", "async-task", "bytemuck", @@ -2233,6 +2678,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "skrifa" version = "0.44.0" @@ -2523,6 +2974,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2589,9 +3051,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2607,6 +3081,17 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2631,6 +3116,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -3480,6 +3976,112 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "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.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zeno" version = "0.3.3" @@ -3567,3 +4169,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow", +] diff --git a/crates/shikra/Cargo.toml b/crates/shikra/Cargo.toml index 540e0a0..55c2bc7 100644 --- a/crates/shikra/Cargo.toml +++ b/crates/shikra/Cargo.toml @@ -10,6 +10,9 @@ default = ["complex-scripts"] complex-scripts = ["parley/complex-scripts"] [dependencies] +accesskit = "0.25" +# Talk to AT-SPI on Linux with tokio, which we already use, rather than async-io. +accesskit_winit = { version = "0.34", default-features = false, features = ["accesskit_unix", "tokio", "rwh_06"] } anyhow = "1" async-task = "4" bytemuck = { version = "1", features = ["derive"] } diff --git a/crates/shikra/examples/input.rs b/crates/shikra/examples/input.rs index 9f23548..77c9cf6 100644 --- a/crates/shikra/examples/input.rs +++ b/crates/shikra/examples/input.rs @@ -1,12 +1,14 @@ //! Mouse and keyboard input: clicks, hover and pressed styles, cursors, focus, -//! and keyboard navigation. +//! and keyboard navigation. The buttons and status line are also exposed to +//! screen readers. //! //! Click the buttons, or Tab to them and press Enter or Space. Type anywhere //! to see the keys you press. +use shikra::accesskit::Live; use shikra::{ Application, ClickEvent, Context, FocusHandle, IntoElement, Key, KeyDownEvent, Keystroke, - ParentElement, Render, Rgba, ScrollDelta, ScrollWheelEvent, SharedString, Window, + ParentElement, Render, Rgba, Role, ScrollDelta, ScrollWheelEvent, SharedString, Window, WindowOptions, div, rgb, size, }; @@ -77,6 +79,9 @@ impl Render for Counter { .active(|s| s.bg(rgb(0x3730a3))) .focus_visible(|s| s.border_color(rgb(0xf59e0b))) .track_focus(handle) + .role(Role::Button) + // Screen readers would read "−" as "minus". + .label(action.label()) .on_click(cx.listener(move |this, event: &ClickEvent, _, cx| { let how = match event.click_count { 1 => "clicked".into(), @@ -161,6 +166,9 @@ impl Render for Counter { .child( div() .style(|s| s.text_sm().text_color(rgb(0x64748b))) + // Announce changes to screen reader users. + .role(Role::Status) + .accessibility(|node| node.set_live(Live::Polite)) .child(self.status.clone()), ) } diff --git a/crates/shikra/src/div.rs b/crates/shikra/src/div.rs index 6e161e9..d47aed9 100644 --- a/crates/shikra/src/div.rs +++ b/crates/shikra/src/div.rs @@ -6,7 +6,7 @@ use crate::window::{Listeners, Node}; use crate::{ AnyElement, App, Bounds, ClickEvent, Element, ElementId, FocusHandle, KeyDownEvent, KeyUpEvent, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, - Rgba, ScrollWheelEvent, Style, Window, px, + Rgba, Role, ScrollWheelEvent, SharedString, Style, Window, px, }; /// A box with a style and children. The basic building block of a UI. @@ -21,6 +21,7 @@ pub fn div() -> Div { focus_handle: None, tab_stop: true, listeners: Listeners::default(), + accessibility: None, children: Vec::new(), } } @@ -35,6 +36,7 @@ pub struct Div { focus_handle: Option, tab_stop: bool, listeners: Listeners, + accessibility: Option>, children: Vec, } @@ -101,6 +103,52 @@ impl Div { self } + /// What the element is to assistive technologies, like screen readers. + /// Like HTML's `role` attribute. + /// + /// Elements without a role are grouped with their parent, unless they can + /// be focused or clicked. + pub fn role(self, role: Role) -> Self { + self.accessibility(|node| node.set_role(role)) + } + + /// The name assistive technologies give the element, for when its text + /// doesn't describe it or it has none, like an icon button. Like HTML's + /// `aria-label`. + /// + /// Elements with roles like [`Role::Button`] are named after their text + /// by default. + pub fn label(self, label: impl Into) -> Self { + let label = label.into(); + self.accessibility(|node| node.set_label(&*label)) + } + + /// More about the element for assistive technologies, beyond its name. + /// Like HTML's `aria-description`. + pub fn description(self, description: impl Into) -> Self { + let description = description.into(); + self.accessibility(|node| node.set_description(&*description)) + } + + /// Edits the element's node in the accessibility tree directly, for + /// properties without their own method. + /// + /// ```ignore + /// div() + /// .role(Role::Switch) + /// .accessibility(|node| node.set_toggled(Toggled::True)) + /// ``` + /// + /// Don't set the node's bounds, children, or actions; they come from the + /// element. + pub fn accessibility(mut self, f: impl FnOnce(&mut accesskit::Node)) -> Self { + let node = self + .accessibility + .get_or_insert_with(|| Box::new(accesskit::Node::new(Role::GenericContainer))); + f(node); + self + } + pub fn on_mouse_down( mut self, button: MouseButton, @@ -273,6 +321,7 @@ impl Element for Div { node.hover_styled = self.hover_style.is_some(); node.active_styled = self.active_style.is_some(); node.listeners = mem::take(&mut self.listeners); + node.accessibility = self.accessibility.take().map(|node| *node); window.with_node(node, |window| { window.with_text_style(&self.style.text, |window| { diff --git a/crates/shikra/src/element.rs b/crates/shikra/src/element.rs index a8f3f94..fdf8613 100644 --- a/crates/shikra/src/element.rs +++ b/crates/shikra/src/element.rs @@ -194,4 +194,8 @@ impl GlobalElementId { local.hash(&mut hasher); Self(hasher.finish()) } + + pub(crate) fn as_u64(self) -> u64 { + self.0 + } } diff --git a/crates/shikra/src/lib.rs b/crates/shikra/src/lib.rs index 0900558..7e7e503 100644 --- a/crates/shikra/src/lib.rs +++ b/crates/shikra/src/lib.rs @@ -21,6 +21,7 @@ mod text; mod view; mod window; +pub use accesskit::{self, Role}; pub use app::*; pub use color::*; pub use context::*; diff --git a/crates/shikra/src/platform.rs b/crates/shikra/src/platform.rs index c0fc1b4..f736790 100644 --- a/crates/shikra/src/platform.rs +++ b/crates/shikra/src/platform.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use winit::application::ApplicationHandler; use winit::dpi::LogicalSize; use winit::event::{ElementState, MouseScrollDelta, WindowEvent}; -use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}; use winit::platform::modifier_supplement::KeyEventExtModifierSupplement; use winit::window::CursorIcon; @@ -26,32 +26,48 @@ use crate::{ pub(crate) type OnLaunch = Box; -/// Sent to the event loop when there are foreground tasks to run. -struct RunForegroundTasks; +/// Sent to the event loop from other threads. +enum UserEvent { + /// There are foreground tasks to run. + RunForegroundTasks, + /// An assistive technology wants something from a window. + AccessKit(accesskit_winit::Event), +} + +impl From for UserEvent { + fn from(event: accesskit_winit::Event) -> Self { + Self::AccessKit(event) + } +} struct PlatformWindow { id: WindowId, window: Arc, surface: WindowSurface, + accessibility: accesskit_winit::Adapter, } struct Platform { app: Rc>, foreground_queue: ForegroundQueue, + proxy: EventLoopProxy, on_launch: Option, renderer: Renderer, windows: HashMap, } pub(crate) fn run(tokio_handle: tokio::runtime::Handle, on_launch: OnLaunch) { - let event_loop = EventLoop::::with_user_event() + let event_loop = EventLoop::::with_user_event() .build() .expect("failed to create event loop"); let proxy = event_loop.create_proxy(); - let foreground_queue = ForegroundQueue::new(move || { - // This only fails once the event loop has exited. - let _ = proxy.send_event(RunForegroundTasks); + let foreground_queue = ForegroundQueue::new({ + let proxy = proxy.clone(); + move || { + // This only fails once the event loop has exited. + let _ = proxy.send_event(UserEvent::RunForegroundTasks); + } }); let app = App::create( @@ -62,6 +78,7 @@ pub(crate) fn run(tokio_handle: tokio::runtime::Handle, on_launch: OnLaunch) { let mut platform = Platform { app, foreground_queue, + proxy, on_launch: Some(on_launch), renderer: Renderer::new(event_loop.owned_display_handle()), windows: HashMap::default(), @@ -72,7 +89,7 @@ pub(crate) fn run(tokio_handle: tokio::runtime::Handle, on_launch: OnLaunch) { .expect("failed to run event loop"); } -impl ApplicationHandler for Platform { +impl ApplicationHandler for Platform { fn resumed(&mut self, event_loop: &ActiveEventLoop) { if let Some(on_launch) = self.on_launch.take() { on_launch(&mut self.app.borrow_mut()); @@ -80,8 +97,28 @@ impl ApplicationHandler for Platform { self.flush(event_loop); } - fn user_event(&mut self, event_loop: &ActiveEventLoop, _: RunForegroundTasks) { - self.foreground_queue.run_pending(); + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + match event { + UserEvent::RunForegroundTasks => self.foreground_queue.run_pending(), + UserEvent::AccessKit(event) => { + let Some(window) = self.windows.get(&event.window_id) else { + return; + }; + let mut app = self.app.borrow_mut(); + app.update_window(window.id, |window, cx| match event.window_event { + accesskit_winit::WindowEvent::InitialTreeRequested => { + window.accessibility.activate(); + window.refresh(); + } + accesskit_winit::WindowEvent::ActionRequested(request) => { + window.dispatch_accessibility_action(request, cx); + } + accesskit_winit::WindowEvent::AccessibilityDeactivated => { + window.accessibility.deactivate(); + } + }); + } + } self.flush(event_loop); } @@ -94,6 +131,7 @@ impl ApplicationHandler for Platform { let Some(window) = self.windows.get_mut(&window_id) else { return; }; + window.accessibility.process_event(&window.window, &event); match event { WindowEvent::CloseRequested => { @@ -217,9 +255,12 @@ impl Platform { continue; } + // The accessibility adapter has to be created before the window is + // shown. let attributes = winit::window::Window::default_attributes() .with_title(&options.title) - .with_inner_size(LogicalSize::new(options.size.width, options.size.height)); + .with_inner_size(LogicalSize::new(options.size.width, options.size.height)) + .with_visible(false); let window = match event_loop.create_window(attributes) { Ok(window) => Arc::new(window), Err(error) => { @@ -239,12 +280,19 @@ impl Platform { if let Some(app_window) = app.windows.get_mut(&id) { sync_window_metrics(app_window, &window); } + let accessibility = accesskit_winit::Adapter::with_event_loop_proxy( + event_loop, + &window, + self.proxy.clone(), + ); + window.set_visible(true); self.windows.insert( window.id(), PlatformWindow { id, window, surface, + accessibility, }, ); } @@ -253,7 +301,7 @@ impl Platform { self.windows .retain(|_, window| app.windows.contains_key(&window.id)); - for platform_window in self.windows.values() { + for platform_window in self.windows.values_mut() { let Some(window) = app.windows.get_mut(&platform_window.id) else { continue; }; @@ -268,6 +316,11 @@ impl Platform { if std::mem::take(&mut window.needs_redraw) { platform_window.window.request_redraw(); } + if std::mem::take(&mut window.accessibility.update_pending) { + platform_window + .accessibility + .update_if_active(|| window.accessibility.tree_update()); + } } } } diff --git a/crates/shikra/src/text.rs b/crates/shikra/src/text.rs index 3f5cbe1..4a1193c 100644 --- a/crates/shikra/src/text.rs +++ b/crates/shikra/src/text.rs @@ -18,6 +18,7 @@ use swash::scale::{Render, ScaleContext, Source, StrikeWith, image::Content}; use swash::zeno::{Angle, Format, Transform, Vector}; use crate::atlas::{AtlasKind, AtlasTile, GlyphAtlas, GlyphKey}; +use crate::element::GlobalElementId; use crate::{ AbsoluteLength, App, AvailableSpace, Bounds, Element, IntoElement, LayoutId, Rgba, Size, Style, Window, point, rems, @@ -525,13 +526,14 @@ impl Text { } impl Element for Text { - type LayoutState = Rc>; + type LayoutState = (GlobalElementId, Rc>); fn request_layout( &mut self, window: &mut Window, cx: &mut App, - ) -> (LayoutId, Rc>) { + ) -> (LayoutId, Self::LayoutState) { + let id = window.with_element_id(None, |_, id| id); let style = window.text_style(); let layout = window.text_layout(&self.text, &style, &mut cx.text_system); let layout_id = window.request_measured_layout(&Style::default(), { @@ -542,16 +544,22 @@ impl Element for Text { .measure(known_dimensions, available_space) } }); - (layout_id, layout) + (layout_id, (id, layout)) } fn paint( &mut self, bounds: Bounds, - layout: &mut Rc>, + (id, layout): &mut Self::LayoutState, window: &mut Window, cx: &mut App, ) { + if window.is_accessibility_active() { + let mut node = accesskit::Node::new(accesskit::Role::Label); + node.set_value(&*self.text); + window.with_accessibility_node(*id, bounds, node, |_, _| {}); + } + let mut layout = layout.borrow_mut(); layout.break_lines(Some(bounds.size.width)); diff --git a/crates/shikra/src/window.rs b/crates/shikra/src/window.rs index a59d91e..3d18fb7 100644 --- a/crates/shikra/src/window.rs +++ b/crates/shikra/src/window.rs @@ -1,3 +1,4 @@ +mod accessibility; mod dispatch; use std::cell::RefCell; @@ -16,6 +17,7 @@ use crate::{ IntoElement, LayoutId, Point, Rgba, SharedString, Size, Style, TextStyle, TextStyleRefinement, TextSystem, px, size, }; +pub(crate) use accessibility::AccessibilityState; pub(crate) use dispatch::{InputState, Listeners, Node}; /// Identifies a window within an [`App`](crate::App). @@ -104,6 +106,7 @@ pub struct Window { text_layout_cache: TextLayoutCache, pub(crate) scene: Scene, pub(crate) input: InputState, + pub(crate) accessibility: AccessibilityState, } impl Window { @@ -122,6 +125,7 @@ impl Window { text_layout_cache: TextLayoutCache::default(), scene: Scene::default(), input: InputState::default(), + accessibility: AccessibilityState::default(), } } @@ -134,6 +138,10 @@ impl Window { if title != self.title { self.title = title; self.title_changed = true; + // The title names the window in the accessibility tree. + if self.is_accessibility_active() { + self.refresh(); + } } } @@ -285,6 +293,7 @@ impl Window { self.text_layout_cache.start_frame(); self.scene.clear(); self.start_input_frame(); + self.start_accessibility_frame(); let mut root = self .root_view @@ -310,6 +319,7 @@ impl Window { self.accessed_entities = cx.stop_tracking_accesses(); self.finish_input_frame(cx); + self.finish_accessibility_frame(self.focused_accessibility_id()); } } diff --git a/crates/shikra/src/window/accessibility.rs b/crates/shikra/src/window/accessibility.rs new file mode 100644 index 0000000..92d77f7 --- /dev/null +++ b/crates/shikra/src/window/accessibility.rs @@ -0,0 +1,381 @@ +//! The accessibility tree, which screen readers and other assistive +//! technologies read, built with AccessKit. +//! +//! Like the dispatch tree, it's built while painting: elements with something +//! to tell assistive technologies (a role, a label, text, or a way to interact +//! with them) add a node, and nodes added while painting an element's children +//! become its children. It's only built while an assistive technology is +//! listening. + +use std::collections::{HashMap, HashSet}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::mem; + +use accesskit::{Affine, Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate}; + +use crate::element::GlobalElementId; +use crate::{Bounds, Window}; + +/// The window's node, which the tree is rooted at. +const ROOT_ID: NodeId = NodeId(0); + +#[derive(Default)] +pub(crate) struct AccessibilityState { + /// Whether an assistive technology is listening, so the tree is built. + active: bool, + /// The nodes added this frame, by ID. + next_nodes: HashMap, + /// The IDs used this frame, including those of nodes still open. + next_ids: HashSet, + /// The nodes whose children are being painted, innermost last. The + /// window's node is at the bottom. + stack: Vec, + /// The nodes added in the frame that was last drawn. + rendered_nodes: HashMap, + rendered_focus: Option, + /// The nodes the platform has, which updates are relative to, or `None` + /// if it needs the whole tree. + sent_nodes: Option>, + /// Whether a frame has been drawn since the platform was last updated. + pub(crate) update_pending: bool, +} + +struct OpenNode { + id: NodeId, + node: Node, + /// The text of the node's descendants, for naming it from its contents. + text: String, +} + +impl AccessibilityState { + /// Starts building the tree, sending the platform all of it after the + /// next frame. + pub(crate) fn activate(&mut self) { + self.active = true; + self.sent_nodes = None; + } + + pub(crate) fn deactivate(&mut self) { + *self = Self::default(); + } + + /// The changes to the tree since the platform was last updated. + pub(crate) fn tree_update(&mut self) -> TreeUpdate { + let tree = self.sent_nodes.is_none().then(|| TreeInfo { + root: ROOT_ID, + toolkit_name: Some("Shikra".into()), + toolkit_version: Some(env!("CARGO_PKG_VERSION").into()), + }); + let sent_nodes = self.sent_nodes.take().unwrap_or_default(); + let nodes = self + .rendered_nodes + .iter() + .filter(|(id, node)| sent_nodes.get(id) != Some(node)) + .map(|(id, node)| (*id, node.clone())) + .collect(); + self.sent_nodes = Some(self.rendered_nodes.clone()); + TreeUpdate { + nodes, + tree, + tree_id: TreeId::ROOT, + focus: self.rendered_focus.unwrap_or(ROOT_ID), + } + } + + /// Adds a node for `element` as a child of the innermost open node, and + /// opens it, returning its ID. + fn push(&mut self, element: GlobalElementId, node: Node) -> NodeId { + // An element's ID is only unique if its siblings' are, and a view can + // be rendered in more than one place, so make sure it is. + let mut id = NodeId(element.as_u64()); + let mut occurrence = 0_u64; + while id == ROOT_ID || self.next_ids.contains(&id) { + occurrence += 1; + let mut hasher = DefaultHasher::new(); + (element, occurrence).hash(&mut hasher); + id = NodeId(hasher.finish()); + } + self.next_ids.insert(id); + + if let Some(parent) = self.stack.last_mut() { + parent.node.push_child(id); + } + self.stack.push(OpenNode { + id, + node, + text: String::new(), + }); + id + } + + /// Closes the innermost open node. + fn pop(&mut self) { + let OpenNode { id, mut node, text } = self.stack.pop().expect("no open node"); + + // Like the web, name controls like buttons after their text. + if node.label().is_none() && !text.is_empty() && is_named_from_contents(node.role()) { + node.set_label(text.clone()); + } + + if !node.is_hidden() + && let Some(parent) = self.stack.last_mut() + { + let text = match (node.label(), node.role()) { + (Some(label), _) => label, + (None, Role::Label) => node.value().unwrap_or_default(), + (None, _) => &text, + }; + if !text.is_empty() { + if !parent.text.is_empty() { + parent.text.push(' '); + } + parent.text.push_str(text); + } + } + + self.next_nodes.insert(id, node); + } +} + +/// Whether a node with `role` is named after its contents when it doesn't +/// have a label, from WAI-ARIA's roles supporting name from content. +fn is_named_from_contents(role: Role) -> bool { + matches!( + role, + Role::Button + | Role::CheckBox + | Role::RadioButton + | Role::Switch + | Role::Link + | Role::MenuItem + | Role::MenuItemCheckBox + | Role::MenuItemRadio + | Role::Tab + | Role::ListBoxOption + | Role::TreeItem + | Role::Cell + | Role::ColumnHeader + | Role::RowHeader + | Role::Heading + | Role::Tooltip + ) +} + +fn rect(bounds: Bounds) -> Rect { + Rect { + x0: bounds.origin.x as f64, + y0: bounds.origin.y as f64, + x1: bounds.right() as f64, + y1: bounds.bottom() as f64, + } +} + +impl Window { + pub(crate) fn is_accessibility_active(&self) -> bool { + self.accessibility.active + } + + /// Adds `node` for `element` to the tree, and runs `f` to paint the + /// element's children, whose nodes become the node's children. + pub(crate) fn with_accessibility_node( + &mut self, + element: GlobalElementId, + bounds: Bounds, + mut node: Node, + f: impl FnOnce(&mut Self, NodeId) -> R, + ) -> R { + node.set_bounds(rect(bounds)); + let id = self.accessibility.push(element, node); + let result = f(self, id); + self.accessibility.pop(); + result + } + + pub(super) fn start_accessibility_frame(&mut self) { + let state = &mut self.accessibility; + state.next_nodes.clear(); + state.next_ids.clear(); + state.stack.clear(); + if state.active { + state.next_ids.insert(ROOT_ID); + state.stack.push(OpenNode { + id: ROOT_ID, + node: Node::new(Role::Window), + text: String::new(), + }); + } + } + + /// Makes the tree just painted the one sent to the platform, with the + /// element whose node is `focus` focused. + pub(super) fn finish_accessibility_frame(&mut self, focus: Option) { + if !self.accessibility.active { + return; + } + + let root = self.accessibility.stack.last_mut().expect("no window node"); + root.node.set_label(self.title.as_str()); + root.node + .set_bounds(rect(Bounds::new(Default::default(), self.viewport_size))); + // Nodes are in logical pixels, and the platform wants device pixels. + root.node + .set_transform(Affine::scale(self.scale_factor as f64)); + self.accessibility.pop(); + + let state = &mut self.accessibility; + state.rendered_nodes = mem::take(&mut state.next_nodes); + state.rendered_focus = focus; + state.update_pending = true; + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use accesskit::{Action, ActionRequest, NodeId, Role, TreeId, TreeUpdate}; + + use crate::test::test_app; + use crate::{ + App, Context, FocusHandle, IntoElement, ParentElement, Render, Window, WindowId, + WindowOptions, div, px, + }; + + struct Counter { + count: Rc>, + button: FocusHandle, + } + + impl Render for Counter { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let count = self.count.clone(); + div() + .child(format!("Count: {}", self.count.get())) + .child( + div() + .style(|s| s.w(px(100.)).h(px(40.))) + .role(Role::Button) + .track_focus(&self.button) + .on_click(move |_, _, _| count.set(count.get() + 1)) + .child("Increment"), + ) + .child(div().role(Role::Button).label("Close").child("×")) + } + } + + fn open(cx: &mut App) -> (WindowId, FocusHandle, Rc>) { + let count = Rc::new(Cell::new(0)); + let button = cx.focus_handle(); + let window = cx + .open_window(WindowOptions::default(), |_, cx| { + cx.new(|_| Counter { + count: count.clone(), + button: button.clone(), + }) + }) + .id(); + (window, button, count) + } + + /// Draws the window and returns the update the platform would get. + fn draw(cx: &mut App, window: WindowId) -> TreeUpdate { + cx.update_window(window, |window, cx| { + window.draw(cx); + assert!(window.accessibility.update_pending); + window.accessibility.tree_update() + }) + .unwrap() + } + + fn find(update: &TreeUpdate, role: Role, label: &str) -> NodeId { + update + .nodes + .iter() + .find(|(_, node)| node.role() == role && (node.label().or(node.value()) == Some(label))) + .unwrap_or_else(|| panic!("no {role:?} {label:?} in {update:#?}")) + .0 + } + + #[test] + fn the_tree_is_only_built_while_active() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let (window, _, _) = open(cx); + + cx.update_window(window, |window, cx| { + window.draw(cx); + assert!(!window.accessibility.update_pending); + }); + } + + #[test] + fn builds_the_tree_and_sends_changes() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let (window, button, _) = open(cx); + cx.update_window(window, |window, _| window.accessibility.activate()); + + let update = draw(cx, window); + let tree = update.tree.as_ref().expect("the first update has the tree"); + let (_, root) = update + .nodes + .iter() + .find(|(id, _)| *id == tree.root) + .unwrap(); + assert_eq!(root.role(), Role::Window); + assert_eq!(root.label(), Some("Shikra")); + + // Buttons are named after their text, unless they have a label. + let increment = find(&update, Role::Button, "Increment"); + find(&update, Role::Button, "Close"); + find(&update, Role::Label, "Count: 0"); + assert_eq!(update.focus, tree.root); + + // Only what changed is sent. + cx.update_window(window, |window, _| window.focus(&button)); + let update = draw(cx, window); + assert!(update.tree.is_none()); + assert!(update.nodes.is_empty()); + assert_eq!(update.focus, increment); + + // Reactivating sends the whole tree again. + cx.update_window(window, |window, _| window.accessibility.activate()); + let update = draw(cx, window); + assert!(update.tree.is_some()); + assert_eq!(update.nodes.len(), 6); + } + + #[test] + fn performs_actions() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let (window, button, count) = open(cx); + cx.update_window(window, |window, _| window.accessibility.activate()); + let update = draw(cx, window); + let increment = find(&update, Role::Button, "Increment"); + + let perform = |cx: &mut App, action| { + cx.update_window(window, |window, cx| { + window.dispatch_accessibility_action( + ActionRequest { + action, + target_tree: TreeId::ROOT, + target_node: increment, + data: None, + }, + cx, + ) + }) + }; + + perform(cx, Action::Click); + assert_eq!(count.get(), 1); + + perform(cx, Action::Focus); + cx.update_window(window, |window, _| { + assert!(button.is_focused(window)); + assert!(button.is_focus_visible(window)); + }); + } +} diff --git a/crates/shikra/src/window/dispatch.rs b/crates/shikra/src/window/dispatch.rs index 9a4325e..7cbc181 100644 --- a/crates/shikra/src/window/dispatch.rs +++ b/crates/shikra/src/window/dispatch.rs @@ -12,13 +12,15 @@ use std::mem; use std::rc::Rc; use std::time::{Duration, Instant}; +use accesskit::{Action, ActionRequest, NodeId, Role, TreeId}; + use crate::element::{GlobalElementId, LocalElementId}; use crate::focus::WeakFocusHandle; use crate::input::PlatformInput; use crate::{ App, Bounds, ClickEvent, CursorStyle, ElementId, EntityId, FocusHandle, FocusId, Key, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, Point, ScrollWheelEvent, Window, + MouseUpEvent, Point, ScrollWheelEvent, Window, point, }; /// How long after one click another counts towards a double click. @@ -54,8 +56,14 @@ pub(crate) struct Node { /// Whether the element looks different when pressed. pub(crate) active_styled: bool, pub(crate) listeners: Listeners, + /// What the element tells assistive technologies about itself, if more + /// than that it can be focused or clicked. + pub(crate) accessibility: Option, /// Set when the node is inserted. parent: Option, + /// The element's node in the accessibility tree, set when the node is + /// inserted if the element has one. + accessibility_id: Option, } impl Node { @@ -69,9 +77,30 @@ impl Node { hover_styled: false, active_styled: false, listeners: Listeners::default(), + accessibility: None, parent: None, + accessibility_id: None, } } + + /// The element's node in the accessibility tree, if it has anything to + /// tell assistive technologies. + fn take_accessibility_node(&mut self) -> Option { + let focusable = self.focus.is_some(); + let clickable = !self.listeners.click.is_empty(); + let mut node = match self.accessibility.take() { + Some(node) => node, + None if focusable || clickable => accesskit::Node::new(Role::GenericContainer), + None => return None, + }; + if focusable { + node.add_action(Action::Focus); + } + if clickable { + node.add_action(Action::Click); + } + Some(node) + } } /// The nodes painted in a frame, in paint order. @@ -297,6 +326,21 @@ impl Window { /// Adds a node for an element being painted, and runs `f` to paint its /// children. pub(crate) fn with_node(&mut self, mut node: Node, f: impl FnOnce(&mut Self) -> R) -> R { + let accessibility_node = self + .is_accessibility_active() + .then(|| node.take_accessibility_node()) + .flatten(); + let Some(accessibility_node) = accessibility_node else { + return self.push_node(node, f); + }; + let (element, bounds) = (node.id, node.bounds); + self.with_accessibility_node(element, bounds, accessibility_node, |window, id| { + node.accessibility_id = Some(id); + window.push_node(node, f) + }) + } + + fn push_node(&mut self, mut node: Node, f: impl FnOnce(&mut Self) -> R) -> R { let frame = &mut self.input.next_frame; node.parent = frame.stack.last().copied(); frame.stack.push(frame.nodes.len()); @@ -306,6 +350,54 @@ impl Window { result } + /// The focused element's node in the accessibility tree. + pub(super) fn focused_accessibility_id(&self) -> Option { + let frame = &self.input.rendered_frame; + let focused = frame.focused_node(self.focused()?)?; + frame.nodes[focused].accessibility_id + } + + /// Performs an action an assistive technology requested, like clicking a + /// button for a screen reader user. + pub(crate) fn dispatch_accessibility_action(&mut self, request: ActionRequest, cx: &mut App) { + if request.target_tree != TreeId::ROOT { + return; + } + let frame = &self.input.rendered_frame; + let Some(target) = frame + .nodes + .iter() + .position(|node| node.accessibility_id == Some(request.target_node)) + else { + return; + }; + + match request.action { + Action::Focus => { + if let Some(focus) = frame.nodes[target].focus.clone() { + // Assistive technologies are how the user is navigating, + // so show where they are. + self.set_focus_visible(true); + self.set_focus(Some(focus)); + } + } + Action::Click => { + let bounds = frame.nodes[target].bounds; + let event = ClickEvent { + position: point( + bounds.origin.x + bounds.size.width / 2., + bounds.origin.y + bounds.size.height / 2., + ), + modifiers: self.input.modifiers, + click_count: 1, + }; + let path = frame.path(Some(target)); + self.dispatch_on_path(&path, &event, |l| &l.click, cx); + } + _ => {} + } + } + pub(super) fn start_input_frame(&mut self) { self.input.next_frame = Frame::default(); self.input.element_id_stack.clear(); -- knotmirror 0.0.1