From 8a4a86b5d4938d41edd3fe06f6ee94453c157f1d Mon Sep 17 00:00:00 2001 From: Christopher Biscardi Date: Tue, 17 Mar 2026 11:10:50 -0700 Subject: [PATCH 1/2] [wip] macos implementation (broken due to strange NSView subclassing issues) Co-authored-by: cameron --- Cargo.lock | 257 ++++++++++++++++++++++++++++---- Cargo.toml | 3 +- crates/gpui/src/element.rs | 2 + crates/gpui/src/window.rs | 2 + crates/gpui_macos/Cargo.toml | 2 + crates/gpui_macos/src/window.rs | 78 ++++++++++ crates/workspace/src/welcome.rs | 3 + 7 files changed, 319 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a02c4f9f8cf944e0a2c5e2082f9e6ec942e50afa..c6641717422c9f30174fe83e60395b7ffbee4e1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,12 +18,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5469156abf83b372574df59b660375739725f7b5a3c78dc47b80f9ec0450c43" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.34.0", "atspi-common", "serde", "zvariant", ] +[[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 0.35.0", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + [[package]] name = "accesskit_consumer" version = "0.34.0" @@ -34,6 +48,30 @@ dependencies = [ "hashbrown 0.16.1", ] +[[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 0.35.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + [[package]] name = "accesskit_unix" version = "0.20.0" @@ -41,7 +79,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14984a017441d2efc3b0fb6a08ef9b83c17719479ba72f1f0c6e08bea9dd92ec" dependencies = [ "accesskit", - "accesskit_atspi_common", + "accesskit_atspi_common 0.17.0", + "async-channel 2.5.0", + "async-executor", + "async-task", + "atspi", + "futures-lite 2.6.1", + "futures-util", + "serde", + "zbus", +] + +[[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 0.18.0", "async-channel 2.5.0", "async-executor", "async-task", @@ -2232,13 +2288,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]] @@ -2280,7 +2345,7 @@ version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" dependencies = [ - "darling 0.20.11", + "darling 0.21.3", "ident_case", "prettyplease", "proc-macro2", @@ -3089,7 +3154,7 @@ dependencies = [ "http_client_tls", "httparse", "log", - "objc2-foundation", + "objc2-foundation 0.3.1", "parking_lot", "paths", "postage", @@ -3996,13 +4061,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.1", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -5113,9 +5178,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]] @@ -7738,8 +7803,8 @@ dependencies = [ "naga 28.0.0", "num_cpus", "objc", - "objc2", - "objc2-metal", + "objc2 0.6.3", + "objc2-metal 0.3.1", "parking", "parking_lot", "pathfinder_geometry", @@ -7784,7 +7849,7 @@ name = "gpui_linux" version = "0.1.0" dependencies = [ "accesskit", - "accesskit_unix", + "accesskit_unix 0.21.0", "anyhow", "as-raw-xcb-connection", "ashpd", @@ -7832,6 +7897,8 @@ dependencies = [ name = "gpui_macos" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_macos", "anyhow", "async-task", "block", @@ -11390,6 +11457,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" @@ -11399,6 +11482,22 @@ 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", +] + [[package]] name = "objc2-audio-toolbox" version = "0.3.1" @@ -11407,11 +11506,11 @@ checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07" 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.1", ] [[package]] @@ -11420,8 +11519,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfc1d11521c211a7ebe17739fc806719da41f56c6b3f949d9861b459188ce910" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.3", + "objc2-foundation 0.3.1", ] [[package]] @@ -11431,10 +11530,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" dependencies = [ "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.1", ] [[package]] @@ -11444,7 +11543,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]] @@ -11454,10 +11565,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]] @@ -11466,6 +11589,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.1" @@ -11473,9 +11608,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -11489,6 +11624,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.1" @@ -11496,11 +11643,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f246c183239540aab1782457b35ab2040d4259175bd1d0c58e46ada7b47a874" 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.1", +] + +[[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]] @@ -12633,6 +12793,17 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.11.3" @@ -12663,6 +12834,16 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" @@ -12689,6 +12870,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -12707,6 +12901,15 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "picker" version = "0.1.0" @@ -21927,7 +22130,7 @@ dependencies = [ name = "zed" version = "0.229.0" dependencies = [ - "accesskit_unix", + "accesskit_unix 0.20.0", "acp_thread", "acp_tools", "action_log", diff --git a/Cargo.toml b/Cargo.toml index abf1f50ae6e43c1cd6a89a2b9666aed4c4a15fbf..a661f84028acb96f3389eb322a0fa4d99b65dc1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -474,7 +474,8 @@ ztracing_macro = { path = "crates/ztracing_macro" } # accesskit = "0.24.0" -accesskit_unix = "0.20.0" # todo! feature flag +accesskit_macos = "0.26.0" +accesskit_unix = "0.21.0" # todo! feature flag agent-client-protocol = { version = "=0.10.2", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index 38e1d612e629c210c92d1334ef9a9fd3b377f2cc..a6191cd6aab9fc725b00038f5b6cf3b5fe255600 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -46,6 +46,8 @@ use std::{ sync::Arc, }; +// div().id("hello").role(Role::Button) + /// Implemented by types that participate in laying out and painting the contents of a window. /// Elements form a tree and are laid out according to web-based layout rules, as implemented by Taffy. /// You can create custom elements by implementing this trait, see the module-level documentation diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 32542f6a03e6d254799bf32d5305d1717dda247c..e328035e520f9adb7af9215644b6112aaa26d4e4 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2543,9 +2543,11 @@ impl Window { #[cfg(any(feature = "inspector", debug_assertions))] self.paint_inspector_hitbox(cx); + if self.is_a11y_active() { let tree_update = self.a11y_nodes.finalize(); + // dbg!(&tree_update); self.platform_window.a11y_tree_update(tree_update); } } diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 06e5d0e7321af523a249f19ec0d5ac50e2da5d3f..208978fd6552ec659ea71e8040fb42bfc07a59cb 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -24,6 +24,8 @@ gpui.workspace = true [target.'cfg(target_os = "macos")'.dependencies] anyhow.workspace = true async-task = "4.7" +accesskit.workspace = true +accesskit_macos.workspace = true block = "0.1" cocoa.workspace = true collections.workspace = true diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index b783a4d083131fac70095d22718796ef761adee3..2586ba8b9130b2e39fe2640f209d8de6409b8b56 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -2,6 +2,7 @@ use crate::{ BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, events::platform_input_from_native, ns_string, renderer, }; +use accesskit_macos::SubclassingAdapter; #[cfg(any(test, feature = "test-support"))] use anyhow::Result; use block::ConcreteBlock; @@ -113,11 +114,38 @@ unsafe extern "C" { ) -> i32; } +// todo! Apache license from accesskit_winit until end +pub struct AdapterWrapper { + adapter: SubclassingAdapter, +} + +impl AdapterWrapper { + pub unsafe fn new( + window: *mut c_void, + activation_handler: impl 'static + accesskit::ActivationHandler, + action_handler: impl 'static + accesskit::ActionHandler, + ) -> Self { + let adapter = unsafe { SubclassingAdapter::for_window(window, activation_handler, action_handler) }; + Self { adapter } + } + + pub fn update_if_active(&mut self, updater: impl FnOnce() -> accesskit::TreeUpdate) { + if let Some(events) = self.adapter.update_if_active(updater) { + events.raise(); + } + } +} +// todo! end + #[ctor] unsafe fn build_classes() { unsafe { WINDOW_CLASS = build_window_class("GPUIWindow", class!(NSWindow)); PANEL_CLASS = build_window_class("GPUIPanel", class!(NSPanel)); + + accesskit_macos::add_focus_forwarder_to_window_class("GPUIWindow"); + accesskit_macos::add_focus_forwarder_to_window_class("GPUIPanel"); + VIEW_CLASS = { let mut decl = ClassDecl::new("GPUIView", class!(NSView)).unwrap(); decl.add_ivar::<*mut c_void>(WINDOW_STATE_IVAR); @@ -442,6 +470,7 @@ struct MacWindowState { activated_least_once: bool, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, + accesskit: Option, } impl MacWindowState { @@ -765,6 +794,7 @@ impl MacWindow { toggle_tab_bar_callback: None, activated_least_once: false, sheet_parent: None, + accesskit: None, }))); (*native_window).set_ivar( @@ -1636,6 +1666,54 @@ 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 state = self.0.lock(); + + if state.accesskit.is_some() { + panic!("cannot initialize accesskit twice"); + } + + // dbg!(state.native_window); + let window = state.native_window as *mut c_void; + // let native_view = state.native_view.as_ptr(); + // unsafe { + // eprintln!( + // "[a11y] native_view class: {}", + // (*native_view).class().name() + // ) + // }; + + let adapter = unsafe { AdapterWrapper::new(window, callbacks.activation, callbacks.action) }; + + unsafe { + let content_view: id = msg_send![state.native_window, contentView]; + let class = (*content_view).class(); + eprintln!( + "[a11y debug] Content view class after adapter: {}", + class.name() + ); + } + state.accesskit = Some(adapter); + } + + fn a11y_tree_update(&mut self, tree_update: accesskit::TreeUpdate) { + let mut state = self.0.lock(); + + let Some(adapter) = &mut state.accesskit else { + panic!("cannot update accesskit tree - not initialized"); + }; + + adapter.update_if_active(|| tree_update); + } + + fn a11y_update_window_bounds(&self) { + // todo! figure this out + } + + fn is_a11y_active(&self) -> bool { + true + } } impl rwh::HasWindowHandle for MacWindow { diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 92f1cb4840731bedda5b0b6751f44bfdcdb8ea52..7050a9ba16ff7fc8f7405e17a1f12e47735aaf10 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -102,6 +102,9 @@ impl RenderOnce for SectionButton { h_flex() .w_full() .justify_between() + .child( + div().id("test-a11y-id").role(gpui::Role::Button).child("hello") + ) .child( h_flex() .gap_2() From 249a78e499ca28512948a024b935ac664bc0d52f Mon Sep 17 00:00:00 2001 From: cameron Date: Tue, 17 Mar 2026 20:29:57 +0000 Subject: [PATCH 2/2] vibe coded a11y example app working --- crates/gpui/Cargo.toml | 4 + crates/gpui/examples/a11y.rs | 984 +++++++++++++++++++++++++++++++++++ 2 files changed, 988 insertions(+) create mode 100644 crates/gpui/examples/a11y.rs diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 0bf19a4878ba80eda3eca02f355b2419f022621e..428612fa37dee1d1901d55cc90b0657dd1d843d7 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -172,6 +172,10 @@ naga.workspace = true +[[example]] +name = "a11y" +path = "examples/a11y.rs" + [[example]] name = "hello_world" path = "examples/hello_world.rs" diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs new file mode 100644 index 0000000000000000000000000000000000000000..aded85d3696752b49413f65d87b0a7abf19c445f --- /dev/null +++ b/crates/gpui/examples/a11y.rs @@ -0,0 +1,984 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +use gpui::{ + App, Bounds, Context, FocusHandle, KeyBinding, Orientation, Role, SharedString, Toggled, + Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +actions!(a11y_example, [Tab, TabPrev, ToggleDarkMode]); + +// --- Data tables demo --- + +struct FileEntry { + name: &'static str, + kind: &'static str, + size: &'static str, +} + +const FILES: &[FileEntry] = &[ + FileEntry { + name: "README.md", + kind: "Markdown", + size: "4 KB", + }, + FileEntry { + name: "main.rs", + kind: "Rust", + size: "12 KB", + }, + FileEntry { + name: "Cargo.toml", + kind: "TOML", + size: "1 KB", + }, + FileEntry { + name: "lib.rs", + kind: "Rust", + size: "8 KB", + }, +]; + +// --- Tree data --- + +struct TreeNode { + label: &'static str, + depth: usize, + children: &'static [TreeNode], +} + +const FILE_TREE: &[TreeNode] = &[ + TreeNode { + label: "src", + depth: 1, + children: &[ + TreeNode { + label: "main.rs", + depth: 2, + children: &[], + }, + TreeNode { + label: "lib.rs", + depth: 2, + children: &[], + }, + ], + }, + TreeNode { + label: "tests", + depth: 1, + children: &[TreeNode { + label: "integration.rs", + depth: 2, + children: &[], + }], + }, + TreeNode { + label: "README.md", + depth: 1, + children: &[], + }, +]; + +// --- App state --- + +struct A11yExample { + focus_handle: FocusHandle, + dark_mode: bool, + notifications_enabled: bool, + auto_save: bool, + selected_tab: usize, + progress: f64, + expanded_tree_nodes: Vec, + selected_tree_node: Option, + selected_file_row: Option, + status_message: SharedString, +} + +impl A11yExample { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle, cx); + + Self { + focus_handle, + dark_mode: false, + notifications_enabled: true, + auto_save: false, + selected_tab: 0, + progress: 0.65, + expanded_tree_nodes: vec![true, true, false], + selected_tree_node: None, + selected_file_row: None, + status_message: "Welcome! This demo showcases GPUI accessibility features.".into(), + } + } + + fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); + } + + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); + } + + fn bg(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0x1e1e2e).into() + } else { + rgb(0xf5f5f5).into() + } + } + + fn fg(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0xcdd6f4).into() + } else { + rgb(0x1e1e2e).into() + } + } + + fn subtle(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0x45475a).into() + } else { + rgb(0xd0d0d0).into() + } + } + + fn surface(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0x313244).into() + } else { + rgb(0xffffff).into() + } + } + + fn accent(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0x89b4fa).into() + } else { + rgb(0x1a73e8).into() + } + } + + fn accent_fg(&self) -> gpui::Hsla { + rgb(0xffffff).into() + } + + fn success(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0xa6e3a1).into() + } else { + rgb(0x2e7d32).into() + } + } + + // --- Section builders --- + + fn render_heading(&self, text: &str) -> impl IntoElement { + div() + .text_lg() + .font_weight(gpui::FontWeight::BOLD) + .text_color(self.fg()) + .mb_1() + .child(text.to_string()) + } + + fn render_tab_bar(&self, cx: &mut Context) -> impl IntoElement { + let tabs = ["Overview", "Settings", "Data"]; + let selected = self.selected_tab; + + div() + .id("tab-bar") + .role(Role::TabList) + .aria_label("Main sections") + .aria_orientation(Orientation::Horizontal) + .flex() + .flex_row() + .gap_1() + .mb_4() + .children(tabs.iter().enumerate().map(|(index, label)| { + let is_selected = index == selected; + div() + .id(("tab", index)) + .role(Role::Tab) + .aria_label(SharedString::from(*label)) + .aria_selected(is_selected) + .aria_position_in_set(index + 1) + .aria_size_of_set(tabs.len()) + .px_4() + .py_1() + .cursor_pointer() + .rounded_t_md() + .font_weight(if is_selected { + gpui::FontWeight::BOLD + } else { + gpui::FontWeight::NORMAL + }) + .text_color(if is_selected { + self.accent() + } else { + self.fg() + }) + .border_b_2() + .border_color(if is_selected { + self.accent() + } else { + gpui::transparent_black() + }) + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_tab = index; + this.status_message = + SharedString::from(format!("Switched to {} tab.", tabs[index])); + cx.notify(); + })) + .child(label.to_string()) + })) + } + + fn render_overview_panel(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("overview-panel") + .role(Role::TabPanel) + .aria_label("Overview") + .flex() + .flex_col() + .gap_4() + .child(self.render_heading("Buttons")) + .child(self.render_buttons(cx)) + .child(self.render_heading("Progress")) + .child(self.render_progress_bar(cx)) + .child(self.render_heading("File Tree")) + .child(self.render_tree(cx)) + } + + fn render_buttons(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("button-group") + .role(Role::Group) + .aria_label("Actions") + .flex() + .flex_row() + .gap_2() + .child( + div() + .id("btn-primary") + .role(Role::Button) + .aria_label("Run build") + .px_4() + .py_1() + .rounded_md() + .bg(self.accent()) + .text_color(self.accent_fg()) + .cursor_pointer() + .hover(|s| s.opacity(0.85)) + .on_click(cx.listener(|this, _, _, cx| { + this.status_message = "Build started!".into(); + this.progress = 0.0; + cx.notify(); + })) + .child("Run Build"), + ) + .child( + div() + .id("btn-increment") + .role(Role::Button) + .aria_label("Increment progress by 10%") + .px_4() + .py_1() + .rounded_md() + .border_1() + .border_color(self.accent()) + .text_color(self.accent()) + .cursor_pointer() + .hover(|s| s.bg(self.accent().opacity(0.1))) + .on_click(cx.listener(|this, _, _, cx| { + this.progress = (this.progress + 0.1).min(1.0); + let pct = (this.progress * 100.0) as u32; + this.status_message = + SharedString::from(format!("Progress: {}%", pct)); + cx.notify(); + })) + .child("+10%"), + ) + .child( + div() + .id("btn-reset") + .role(Role::Button) + .aria_label("Reset progress") + .px_4() + .py_1() + .rounded_md() + .border_1() + .border_color(self.subtle()) + .text_color(self.fg()) + .cursor_pointer() + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(|this, _, _, cx| { + this.progress = 0.0; + this.status_message = "Progress reset.".into(); + cx.notify(); + })) + .child("Reset"), + ) + } + + fn render_progress_bar(&self, cx: &mut Context) -> impl IntoElement { + let pct = (self.progress * 100.0) as u32; + let bar_color = if self.progress >= 1.0 { + self.success() + } else { + self.accent() + }; + + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .id("progress-bar") + .role(Role::ProgressIndicator) + .aria_label("Build progress") + .aria_numeric_value(self.progress * 100.0) + .aria_min_numeric_value(0.0) + .aria_max_numeric_value(100.0) + .h(px(12.0)) + .w_full() + .rounded_full() + .bg(self.subtle().opacity(0.5)) + .overflow_hidden() + .child( + div() + .h_full() + .w(gpui::relative(self.progress as f32)) + .rounded_full() + .bg(bar_color), + ), + ) + .child( + div() + .text_xs() + .text_color(self.fg().opacity(0.7)) + .child(format!("{}% complete", pct)), + ) + .child( + div() + .flex() + .flex_row() + .gap_2() + .mt_1() + .children((0..5).map(|index| { + let step_progress = (index as f64 + 1.0) * 0.2; + let is_done = self.progress >= step_progress; + div() + .id(("progress-step", index)) + .role(Role::ListItem) + .aria_label(SharedString::from(format!("Step {}", index + 1))) + .aria_position_in_set(index + 1) + .aria_size_of_set(5) + .size_6() + .rounded_full() + .flex() + .justify_center() + .items_center() + .text_xs() + .bg(if is_done { + bar_color + } else { + self.subtle().opacity(0.5) + }) + .text_color(if is_done { + self.accent_fg() + } else { + self.fg().opacity(0.5) + }) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + this.progress = step_progress; + let pct = (step_progress * 100.0) as u32; + this.status_message = + SharedString::from(format!("Progress set to {}%.", pct)); + cx.notify(); + })) + .child(format!("{}", index + 1)) + })), + ) + } + + fn render_tree(&self, cx: &mut Context) -> impl IntoElement { + let mut flat_index = 0usize; + + div() + .id("file-tree") + .role(Role::Tree) + .aria_label("Project files") + .flex() + .flex_col() + .border_1() + .border_color(self.subtle()) + .rounded_md() + .p_2() + .children(FILE_TREE.iter().enumerate().flat_map( + |(root_index, node)| { + let mut items = Vec::new(); + let current_index = flat_index; + let is_expanded = self + .expanded_tree_nodes + .get(root_index) + .copied() + .unwrap_or(false); + let is_selected = self.selected_tree_node == Some(current_index); + let has_children = !node.children.is_empty(); + + items.push( + div() + .id(("tree-node", current_index)) + .role(Role::TreeItem) + .aria_label(SharedString::from(node.label)) + .aria_level(node.depth) + .aria_selected(is_selected) + .aria_position_in_set(root_index + 1) + .aria_size_of_set(FILE_TREE.len()) + .when(has_children, |this| this.aria_expanded(is_expanded)) + .pl(px(node.depth as f32 * 16.0)) + .py(px(2.0)) + .px_2() + .rounded_sm() + .cursor_pointer() + .text_color(self.fg()) + .when(is_selected, |this| { + this.bg(self.accent().opacity(0.15)) + }) + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_tree_node = Some(current_index); + if has_children { + if let Some(val) = + this.expanded_tree_nodes.get_mut(root_index) + { + *val = !*val; + } + } + this.status_message = SharedString::from(format!( + "Selected: {}", + node.label + )); + cx.notify(); + })) + .child(format!( + "{} {}", + if has_children { + if is_expanded { + "▾" + } else { + "▸" + } + } else { + " " + }, + node.label + )), + ); + flat_index += 1; + + if has_children && is_expanded { + for (child_index, child) in node.children.iter().enumerate() { + let child_flat_index = flat_index; + let child_is_selected = + self.selected_tree_node == Some(child_flat_index); + + items.push( + div() + .id(("tree-node", child_flat_index)) + .role(Role::TreeItem) + .aria_label(SharedString::from(child.label)) + .aria_level(child.depth) + .aria_selected(child_is_selected) + .aria_position_in_set(child_index + 1) + .aria_size_of_set(node.children.len()) + .pl(px(child.depth as f32 * 16.0)) + .py(px(2.0)) + .px_2() + .rounded_sm() + .cursor_pointer() + .text_color(self.fg()) + .when(child_is_selected, |this| { + this.bg(self.accent().opacity(0.15)) + }) + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_tree_node = Some(child_flat_index); + this.status_message = SharedString::from(format!( + "Selected: {}", + child.label + )); + cx.notify(); + })) + .child(format!(" {}", child.label)), + ); + flat_index += 1; + } + } + + items + }, + )) + } + + fn render_settings_panel(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("settings-panel") + .role(Role::TabPanel) + .aria_label("Settings") + .flex() + .flex_col() + .gap_4() + .child(self.render_heading("Preferences")) + .child( + div() + .id("settings-group") + .role(Role::Group) + .aria_label("Application preferences") + .flex() + .flex_col() + .gap_3() + .child(self.render_toggle( + "dark-mode", + "Dark mode", + self.dark_mode, + Role::Switch, + cx, + |this, _, _, cx| { + this.dark_mode = !this.dark_mode; + this.status_message = if this.dark_mode { + "Dark mode enabled.".into() + } else { + "Dark mode disabled.".into() + }; + cx.notify(); + }, + )) + .child(self.render_toggle( + "notifications", + "Enable notifications", + self.notifications_enabled, + Role::Switch, + cx, + |this, _, _, cx| { + this.notifications_enabled = !this.notifications_enabled; + this.status_message = if this.notifications_enabled { + "Notifications enabled.".into() + } else { + "Notifications disabled.".into() + }; + cx.notify(); + }, + )) + .child(self.render_toggle( + "auto-save", + "Auto-save files", + self.auto_save, + Role::CheckBox, + cx, + |this, _, _, cx| { + this.auto_save = !this.auto_save; + this.status_message = if this.auto_save { + "Auto-save enabled.".into() + } else { + "Auto-save disabled.".into() + }; + cx.notify(); + }, + )), + ) + } + + fn render_toggle( + &self, + id: &'static str, + label: &'static str, + value: bool, + role: Role, + cx: &mut Context, + on_click: impl Fn(&mut Self, &gpui::ClickEvent, &mut Window, &mut Context) + 'static, + ) -> impl IntoElement { + let toggled = if value { + Toggled::True + } else { + Toggled::False + }; + + let is_switch = role == Role::Switch; + + div() + .flex() + .flex_row() + .items_center() + .gap_3() + .child( + div() + .id(id) + .role(role) + .aria_label(SharedString::from(label)) + .aria_toggled(toggled) + .cursor_pointer() + .on_click(cx.listener(on_click)) + .when(is_switch, |this| { + this.w(px(40.0)) + .h(px(22.0)) + .rounded_full() + .bg(if value { + self.accent() + } else { + self.subtle() + }) + .p(px(2.0)) + .child( + div() + .size(px(18.0)) + .rounded_full() + .bg(gpui::white()) + .when(value, |this| this.ml(px(18.0))), + ) + }) + .when(!is_switch, |this| { + this.size(px(18.0)) + .rounded_sm() + .border_2() + .border_color(if value { + self.accent() + } else { + self.subtle() + }) + .bg(if value { + self.accent() + } else { + gpui::transparent_black() + }) + .flex() + .justify_center() + .items_center() + .text_xs() + .text_color(self.accent_fg()) + .when(value, |this| this.child("✓")) + }), + ) + .child( + div() + .text_color(self.fg()) + .child(label.to_string()), + ) + } + + fn render_data_panel(&self, cx: &mut Context) -> impl IntoElement { + let column_count = 3; + let row_count = FILES.len(); + + div() + .id("data-panel") + .role(Role::TabPanel) + .aria_label("Data") + .flex() + .flex_col() + .gap_4() + .child(self.render_heading("File Table")) + .child( + div() + .id("file-table") + .role(Role::Table) + .aria_label("Project files") + .aria_row_count(row_count + 1) + .aria_column_count(column_count) + .flex() + .flex_col() + .border_1() + .border_color(self.subtle()) + .rounded_md() + .overflow_hidden() + .child( + div() + .id("table-header") + .role(Role::Row) + .aria_row_index(1) + .flex() + .flex_row() + .bg(self.subtle().opacity(0.3)) + .font_weight(gpui::FontWeight::BOLD) + .text_color(self.fg()) + .child(self.render_cell("header-name", "Name", 1, column_count, true)) + .child(self.render_cell("header-type", "Type", 2, column_count, true)) + .child(self.render_cell("header-size", "Size", 3, column_count, true)), + ) + .children(FILES.iter().enumerate().map(|(row_index, file)| { + let is_selected = self.selected_file_row == Some(row_index); + + div() + .id(("table-row", row_index)) + .role(Role::Row) + .aria_row_index(row_index + 2) + .aria_selected(is_selected) + .flex() + .flex_row() + .cursor_pointer() + .text_color(self.fg()) + .when(is_selected, |this| { + this.bg(self.accent().opacity(0.15)) + }) + .when(row_index % 2 == 1, |this| { + this.bg(self.subtle().opacity(0.1)) + }) + .hover(|s| s.bg(self.accent().opacity(0.1))) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_file_row = Some(row_index); + this.status_message = SharedString::from(format!( + "Selected file: {}", + FILES[row_index].name + )); + cx.notify(); + })) + .child(self.render_cell( + ("cell-name", row_index), + file.name, + 1, + column_count, + false, + )) + .child(self.render_cell( + ("cell-type", row_index), + file.kind, + 2, + column_count, + false, + )) + .child(self.render_cell( + ("cell-size", row_index), + file.size, + 3, + column_count, + false, + )) + })), + ) + .child(self.render_heading("Item List")) + .child(self.render_list()) + } + + fn render_cell( + &self, + id: impl Into, + text: &str, + column: usize, + total_columns: usize, + is_header: bool, + ) -> impl IntoElement { + div() + .id(id.into()) + .role(if is_header { + Role::ColumnHeader + } else { + Role::Cell + }) + .aria_label(SharedString::from(text.to_string())) + .aria_column_index(column) + .aria_column_count(total_columns) + .flex_1() + .px_3() + .py_2() + .child(text.to_string()) + } + + fn render_list(&self) -> impl IntoElement { + let items = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]; + + div() + .id("demo-list") + .role(Role::List) + .aria_label("Greek letters") + .flex() + .flex_col() + .border_1() + .border_color(self.subtle()) + .rounded_md() + .children(items.iter().enumerate().map(|(index, label)| { + div() + .id(("list-item", index)) + .role(Role::ListItem) + .aria_label(SharedString::from(*label)) + .aria_position_in_set(index + 1) + .aria_size_of_set(items.len()) + .px_3() + .py_1() + .text_color(self.fg()) + .when(index % 2 == 1, |this| { + this.bg(self.subtle().opacity(0.1)) + }) + .child(format!("{}. {}", index + 1, label)) + })) + } + + fn render_status_bar(&self) -> impl IntoElement { + div() + .id("status-bar") + .role(Role::Status) + .aria_label(self.status_message.clone()) + .w_full() + .px_4() + .py_2() + .bg(self.subtle().opacity(0.3)) + .rounded_md() + .text_sm() + .text_color(self.fg().opacity(0.8)) + .child(self.status_message.clone()) + } +} + +impl Render for A11yExample { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tab_content: gpui::AnyElement = match self.selected_tab { + 0 => self.render_overview_panel(cx).into_any_element(), + 1 => self.render_settings_panel(cx).into_any_element(), + 2 => self.render_data_panel(cx).into_any_element(), + _ => div().child("Unknown tab").into_any_element(), + }; + + div() + .id("app-root") + .role(Role::Application) + .aria_label("Accessibility Demo") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_tab)) + .on_action(cx.listener(Self::on_tab_prev)) + .size_full() + .flex() + .flex_col() + .bg(self.bg()) + .font_family("sans-serif") + .child( + div() + .id("header") + .role(Role::Banner) + .aria_label("Application header") + .w_full() + .px_6() + .py_3() + .bg(self.surface()) + .border_b_1() + .border_color(self.subtle()) + .flex() + .flex_row() + .items_center() + .justify_between() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + div() + .text_xl() + .font_weight(gpui::FontWeight::BOLD) + .text_color(self.accent()) + .child("♿"), + ) + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::BOLD) + .text_color(self.fg()) + .child("GPUI Accessibility Demo"), + ), + ) + .child( + div() + .id("theme-toggle") + .role(Role::Button) + .aria_label(if self.dark_mode { + "Switch to light mode" + } else { + "Switch to dark mode" + }) + .px_3() + .py_1() + .rounded_md() + .cursor_pointer() + .border_1() + .border_color(self.subtle()) + .text_color(self.fg()) + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(|this, _, _, cx| { + this.dark_mode = !this.dark_mode; + this.status_message = if this.dark_mode { + "Dark mode enabled.".into() + } else { + "Dark mode disabled.".into() + }; + cx.notify(); + })) + .child(if self.dark_mode { "☀ Light" } else { "🌙 Dark" }), + ), + ) + .child( + div() + .id("main-content") + .role(Role::Main) + .aria_label("Main content") + .flex_1() + .overflow_y_scroll() + .px_6() + .py_4() + .flex() + .flex_col() + .gap_2() + .child(self.render_tab_bar(cx)) + .child(tab_content), + ) + .child( + div() + .id("footer") + .role(Role::ContentInfo) + .aria_label("Status") + .px_6() + .py_2() + .border_t_1() + .border_color(self.subtle()) + .child(self.render_status_bar()), + ) + } +} + +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(800.), px(700.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| A11yExample::new(window, cx)), + ) + .unwrap(); + + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +}