From ab43548338e71013d132d7129ef8e7c4e72fe7dd Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Mon, 13 Jul 2026 17:37:28 +0100 Subject: [PATCH] a11y: Landmarks and menu improvements (#60397) Fixes menu a11y, and adds landmarks with `F6`-navigation. Also fixes a GPUI bug, and adds debug actions for dumping a11y tree info. Since `F6` was already in use by the pause debugger keybind, also tightens up the debugger keybind context so they require an active debugger session. When there is one active, `F6` stays as pause debugger. `ctrl-F6` always works to go to the next landmark. Also adds an "accessible mode" setting. Currently, this only controls whether we show all menus all the time, but I suspect it will expand significantly in the future. Also adds `.aria_keyshortcuts()` API, but it's not wired up within accesskit adapters, so is not yet reported to screen readers. --- Release Notes: - N/A or Added/Fixed/Improved ... --- Cargo.lock | 12 + Cargo.toml | 2 +- assets/keymaps/default-linux.json | 28 +- assets/keymaps/default-macos.json | 29 +- assets/keymaps/default-windows.json | 27 +- assets/settings/default.json | 3 + .../src/activity_indicator.rs | 1 + crates/diagnostics/src/items.rs | 23 ++ .../src/edit_prediction_button.rs | 16 +- .../src/active_buffer_encoding.rs | 1 + crates/git_ui/src/conflict_view.rs | 2 + crates/go_to_line/src/cursor_position.rs | 5 + crates/gpui/src/element.rs | 32 ++ crates/gpui/src/elements/div.rs | 240 ++++++++---- crates/gpui/src/tab_stop.rs | 4 + crates/gpui/src/view.rs | 7 + crates/gpui/src/window.rs | 14 +- crates/gpui/src/window/a11y.rs | 104 +++++- crates/gpui/src/window/a11y/debug.rs | 330 ++++++++++++++++ .../src/active_buffer_language.rs | 4 +- crates/language_tools/src/lsp_button.rs | 2 + .../src/line_ending_indicator.rs | 1 + crates/search/src/search_status_button.rs | 2 + crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/workspace.rs | 5 + crates/settings_ui/src/components/dropdown.rs | 12 + .../settings_ui/src/components/input_field.rs | 13 + .../src/components/number_field.rs | 16 + .../src/components/ollama_model_picker.rs | 23 +- crates/settings_ui/src/page_data.rs | 14 + .../src/pages/audio_input_output_setup.rs | 20 +- .../src/pages/audio_test_window.rs | 4 + crates/settings_ui/src/settings_ui.rs | 79 ++-- crates/title_bar/src/application_menu.rs | 23 +- crates/title_bar/src/title_bar.rs | 38 +- .../src/active_toolchain.rs | 1 + crates/ui/src/components/button/button.rs | 38 +- .../ui/src/components/button/button_like.rs | 41 +- crates/ui/src/components/context_menu.rs | 66 +++- crates/ui/src/components/dropdown_menu.rs | 47 +++ crates/ui/src/components/keybinding.rs | 44 +++ crates/ui/src/components/list/list_item.rs | 33 +- crates/ui/src/components/toggle.rs | 22 +- crates/workspace/src/dock.rs | 2 + crates/workspace/src/status_bar.rs | 64 +++- crates/workspace/src/workspace.rs | 352 +++++++++++++++++- crates/workspace/src/workspace_settings.rs | 38 +- crates/zed/src/zed.rs | 70 ++++ 48 files changed, 1790 insertions(+), 165 deletions(-) create mode 100644 crates/gpui/src/window/a11y/debug.rs diff --git a/Cargo.lock b/Cargo.lock index 5fec34525d..c052ace3eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,7 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ + "enumn", "uuid", ] @@ -5992,6 +5993,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 90eb3c0c4e..45086218b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -504,7 +504,7 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -accesskit = "0.24.0" +accesskit = { version = "0.24.0", features = ["enumn"] } accesskit_macos = "0.26.0" accesskit_unix = "0.21.0" accesskit_windows = "0.33.1" diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 0e9ec9eda7..83212d3f06 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -34,12 +34,6 @@ "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", - "shift-f5": "debugger::Stop", - "ctrl-shift-f5": "debugger::RerunSession", - "f6": "debugger::Pause", - "f7": "debugger::StepOver", - "ctrl-f11": "debugger::StepInto", - "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RatePredictions", "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", @@ -629,6 +623,13 @@ { "context": "Workspace", "bindings": { + // Move focus between the major regions of the window (editor, open + // panels, status bar). Accessibility navigation; mirrors VS Code's + // "Focus Next/Previous Part". `ctrl-f6` mirrors `f6` so region navigation + // stays available even while a debug session rebinds plain `f6` to Pause. + "f6": "workspace::FocusNextPart", + "shift-f6": "workspace::FocusPreviousPart", + "ctrl-f6": "workspace::FocusNextPart", "alt-open": "projects::OpenRecent", // Change the default action on `menu::Confirm` by setting the parameter // "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }], @@ -1097,6 +1098,21 @@ "alt-shift-escape": "git::ToggleFillCommitEditor", }, }, + { + // While a debug session is live, debugger controls take over their F-keys. + // `f6` becomes Pause (region navigation stays on `ctrl-f6` / `shift-f6`), + // and step/stop/rerun are scoped here so they're inert when not debugging. + // `debugger::Start` stays global so a session can be started from anywhere. + "context": "Workspace && debugger_session", + "bindings": { + "f6": "debugger::Pause", + "shift-f5": "debugger::Stop", + "ctrl-shift-f5": "debugger::RerunSession", + "f7": "debugger::StepOver", + "ctrl-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", + }, + }, { "context": "DebugPanel", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index e1f30d0a03..95eacd8f8c 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -4,12 +4,6 @@ "use_key_equivalents": true, "bindings": { "f4": "debugger::Start", - "shift-f5": "debugger::Stop", - "shift-cmd-f5": "debugger::RerunSession", - "f6": "debugger::Pause", - "f7": "debugger::StepOver", - "ctrl-f11": "debugger::StepInto", - "shift-f11": "debugger::StepOut", "home": "menu::SelectFirst", "shift-pageup": "menu::SelectFirst", "pageup": "menu::SelectFirst", @@ -694,6 +688,13 @@ "context": "Workspace", "use_key_equivalents": true, "bindings": { + // Move focus between the major regions of the window (editor, open + // panels, status bar). Accessibility navigation; mirrors VS Code's + // "Focus Next/Previous Part". `cmd-f6` mirrors `f6` so region navigation + // stays available even while a debug session rebinds plain `f6` to Pause. + "f6": "workspace::FocusNextPart", + "shift-f6": "workspace::FocusPreviousPart", + "cmd-f6": "workspace::FocusNextPart", // Change the default action on `menu::Confirm` by setting the parameter // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], "alt-cmd-o": "projects::OpenRecent", @@ -1158,6 +1159,22 @@ "alt-tab": "git::GenerateCommitMessage", }, }, + { + // While a debug session is live, debugger controls take over their F-keys. + // `f6` becomes Pause (region navigation stays on `cmd-f6` / `shift-f6`), + // and step/stop/rerun are scoped here so they're inert when not debugging. + // `debugger::Start` stays global so a session can be started from anywhere. + "context": "Workspace && debugger_session", + "use_key_equivalents": true, + "bindings": { + "f6": "debugger::Pause", + "shift-f5": "debugger::Stop", + "shift-cmd-f5": "debugger::RerunSession", + "f7": "debugger::StepOver", + "ctrl-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", + }, + }, { "context": "DebugPanel", "bindings": { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 0e3932a7db..a000c2506a 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -33,11 +33,6 @@ "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", - "shift-f5": "debugger::Stop", - "ctrl-shift-f5": "debugger::RerunSession", - "f6": "debugger::Pause", - "f10": "debugger::StepOver", - "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-shift-i": "edit_prediction::ToggleMenu", "shift-alt-l": "lsp_tool::ToggleMenu", @@ -627,6 +622,13 @@ "context": "Workspace", "use_key_equivalents": true, "bindings": { + // Move focus between the major regions of the window (editor, open + // panels, status bar). Accessibility navigation; mirrors VS Code's + // "Focus Next/Previous Part". `ctrl-f6` mirrors `f6` so region navigation + // stays available even while a debug session rebinds plain `f6` to Pause. + "f6": "workspace::FocusNextPart", + "shift-f6": "workspace::FocusPreviousPart", + "ctrl-f6": "workspace::FocusNextPart", // Change the default action on `menu::Confirm` by setting the parameter // "ctrl-alt-o": ["projects::OpenRecent", { "create_new_window": true }], "ctrl-r": "projects::OpenRecent", @@ -1094,6 +1096,21 @@ "alt-shift-escape": "git::ToggleFillCommitEditor", }, }, + { + // While a debug session is live, debugger controls take over their F-keys. + // `f6` becomes Pause (region navigation stays on `ctrl-f6` / `shift-f6`), + // and step/stop/rerun are scoped here so they're inert when not debugging. + // `debugger::Start` stays global so a session can be started from anywhere. + "context": "Workspace && debugger_session", + "use_key_equivalents": true, + "bindings": { + "f6": "debugger::Pause", + "shift-f5": "debugger::Stop", + "ctrl-shift-f5": "debugger::RerunSession", + "f10": "debugger::StepOver", + "shift-f11": "debugger::StepOut", + }, + }, { "context": "DebugPanel", "use_key_equivalents": true, diff --git a/assets/settings/default.json b/assets/settings/default.json index 0e42d27e98..1d4ea7a1f5 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -233,6 +233,9 @@ // // Default: "client" "window_decorations": "client", + // Whether to optimize Zed's interface for assistive technology such as screen + // readers. + "accessible_mode": false, // Whether to use the system provided dialogs for Open and Save As. // When set to false, Zed will use the built-in keyboard-first pickers. "use_system_path_prompts": true, diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 4ca66790b0..70871044e6 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -671,6 +671,7 @@ impl Render for ActivityIndicator { } }) .label_size(LabelSize::Small) + .tab_index(0isize) .map(|this| match content.icon { ActivityIcon::LoadingSpinner => this.loading(true), ActivityIcon::Icon(icon_name) => this.start_icon( diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 9f243c7810..724d58e3fc 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -74,6 +74,7 @@ impl Render for DiagnosticIndicator { Button::new("diagnostic_message", SharedString::new(message)) .label_size(LabelSize::Small) .truncate(true) + .tab_index(0isize) .tooltip(move |_window, cx| { Tooltip::for_action( tooltip, @@ -89,10 +90,32 @@ impl Render for DiagnosticIndicator { None }; + let diagnostics_label = match (self.summary.error_count, self.summary.warning_count) { + (0, 0) => "Project diagnostics: no problems".to_string(), + (errors, warnings) => { + let mut parts = Vec::new(); + if errors > 0 { + parts.push(format!( + "{errors} error{}", + if errors == 1 { "" } else { "s" } + )); + } + if warnings > 0 { + parts.push(format!( + "{warnings} warning{}", + if warnings == 1 { "" } else { "s" } + )); + } + format!("Project diagnostics: {}", parts.join(", ")) + } + }; + indicator .child( ButtonLike::new("diagnostic-indicator") .child(diagnostic_indicator) + .tab_index(0isize) + .aria_label(diagnostics_label) .tooltip(move |_window, cx| { Tooltip::for_action("Project Diagnostics", &Deploy, cx) }) diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 46a2dc6c74..703fe9f3d8 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -108,6 +108,8 @@ impl Render for EditPredictionButton { return div().child( IconButton::new("copilot-error", icon) .icon_size(IconSize::Small) + .tab_index(0isize) + .aria_label("GitHub Copilot") .on_click(cx.listener(move |_, _, window, cx| { if let Some(workspace) = Workspace::for_window(window, cx) { workspace.update(cx, |workspace, cx| { @@ -172,7 +174,9 @@ impl Render for EditPredictionButton { }) .anchor(Anchor::BottomRight) .trigger_with_tooltip( - IconButton::new("copilot-icon", icon), + IconButton::new("copilot-icon", icon) + .tab_index(0isize) + .aria_label("GitHub Copilot"), |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx), ) .with_handle(self.popover_menu_handle.clone()), @@ -218,6 +222,8 @@ impl Render for EditPredictionButton { .trigger_with_tooltip( IconButton::new("codestral-icon", IconName::AiMistral) .shape(IconButtonShape::Square) + .tab_index(0isize) + .aria_label("Edit Prediction") .when(!has_api_key, |this| { this.indicator(Indicator::dot().color(Color::Error)) .indicator_border_color(Some( @@ -262,6 +268,8 @@ impl Render for EditPredictionButton { .trigger( IconButton::new("openai-compatible-api-icon", IconName::AiOpenAiCompat) .shape(IconButtonShape::Square) + .tab_index(0isize) + .aria_label("Edit Prediction") .when(!enabled, |this| { this.indicator(Indicator::dot().color(Color::Ignored)) .indicator_border_color(Some( @@ -292,6 +300,8 @@ impl Render for EditPredictionButton { .trigger_with_tooltip( IconButton::new("ollama-icon", IconName::AiOllama) .shape(IconButtonShape::Square) + .tab_index(0isize) + .aria_label("Edit Prediction") .when(!enabled, |this| { this.indicator(Indicator::dot().color(Color::Ignored)) .indicator_border_color(Some( @@ -375,6 +385,8 @@ impl Render for EditPredictionButton { return div().child( IconButton::new("zed-predict-pending-button", ep_icon) .shape(IconButtonShape::Square) + .tab_index(0isize) + .aria_label("Edit Predictions") .indicator(Indicator::dot().color(Color::Muted)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)) .tooltip(move |_window, cx| { @@ -430,6 +442,8 @@ impl Render for EditPredictionButton { let icon_button = IconButton::new("zed-predict-pending-button", ep_icon) .shape(IconButtonShape::Square) + .tab_index(0isize) + .aria_label("Edit Prediction") .when_some(indicator_color, |this, color| { this.indicator(Indicator::dot().color(color)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)) diff --git a/crates/encoding_selector/src/active_buffer_encoding.rs b/crates/encoding_selector/src/active_buffer_encoding.rs index 6d78234314..280886dfdf 100644 --- a/crates/encoding_selector/src/active_buffer_encoding.rs +++ b/crates/encoding_selector/src/active_buffer_encoding.rs @@ -88,6 +88,7 @@ impl Render for ActiveBufferEncoding { div().child( Button::new("change-encoding", text) .label_size(LabelSize::Small) + .tab_index(0isize) .on_click(cx.listener(move |this, _, window, cx| { if disabled { return; diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 1199afb9d4..ea30f61bde 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -642,6 +642,8 @@ impl Render for MergeConflictIndicator { .border_color(border_color) .child( ButtonLike::new("update-button") + .tab_index(0isize) + .aria_label(message.clone()) .child( h_flex() .h_full() diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index f1f3a61c97..29f1c0af13 100644 --- a/crates/go_to_line/src/cursor_position.rs +++ b/crates/go_to_line/src/cursor_position.rs @@ -225,6 +225,11 @@ impl Render for CursorPosition { el.child( Button::new("go-to-line-column", text) .label_size(LabelSize::Small) + .tab_index(0isize) + .aria_label(format!( + "Line {}, column {}", + position.line, position.character + )) .on_click(cx.listener(|this, _, window, cx| { if let Some(workspace) = this.workspace.upgrade() { workspace.update(cx, |workspace, cx| { diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index dbd82b1c2e..c817492b94 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -378,6 +378,24 @@ impl Drawable { 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); + #[cfg(debug_assertions)] + if pushed_a11y_node { + let view = window + .a11y + .view_type_names + .get(&window.current_view()) + .copied(); + let source_location = self.element.source_location(); + window.a11y.nodes.record_node_info( + node_id, + crate::window::a11y::debug::NodeDebugInfo { + synthetic: false, + view, + element_id: global_id.0.last().map(|id| format!("{id:?}")), + source_location, + }, + ); + } } } } @@ -395,10 +413,24 @@ impl Drawable { if pushed_a11y_node { if let Some(global_id) = global_id.as_ref() { + #[cfg(debug_assertions)] + let creator = crate::window::a11y::debug::NodeCreator { + view: window + .a11y + .view_type_names + .get(&window.current_view()) + .copied(), + element_id: global_id.0.last().map(|id| format!("{id:?}")), + source_location: self.element.source_location(), + }; let mut builder = A11ySubtreeBuilder::new( global_id.accesskit_node_id(), &mut window.a11y.nodes, ); + #[cfg(debug_assertions)] + { + builder = builder.with_creator(creator); + } self.element .a11y_synthetic_children(&mut prepaint, &mut builder); } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 04692c86a5..e0c5e623ab 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1225,7 +1225,26 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Set the accessible label for this element. fn aria_label(mut self, label: impl Into) -> Self { - self.interactivity().aria_label = Some(label.into()); + self.interactivity().aria.label = Some(label.into()); + self + } + + /// Set the accessible description for this element. Unlike the label (which + /// names the element), the description provides supplementary information + /// that assistive technology announces after the name, role, and value - + /// for example a settings subtitle or a hint. + fn aria_description(mut self, description: impl Into) -> Self { + self.interactivity().aria.description = Some(description.into()); + self + } + + /// Set the keyboard shortcut(s) that activate this element, announced by + /// assistive technology (maps to AccessKit's `keyboard_shortcut`). + /// + /// Note that this does not create a keymap. It simply instructs assistive + /// technology what the keymap is. + fn aria_keyshortcuts(mut self, keyshortcuts: impl Into) -> Self { + self.interactivity().aria.keyshortcuts = Some(keyshortcuts.into()); self } @@ -1268,106 +1287,106 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Set the selected state for this element. fn aria_selected(mut self, selected: bool) -> Self { - self.interactivity().aria_selected = Some(selected); + 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.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.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.interactivity().aria.numeric_value = Some(value); self } /// Set the step by which assistive technology should expect the numeric /// value of this element to change (e.g. when incrementing a spin button). fn aria_numeric_value_step(mut self, step: f64) -> Self { - self.interactivity().aria_numeric_value_step = Some(step); + self.interactivity().aria.numeric_value_step = Some(step); self } /// Set the string value of this element, e.g. the text content of a simple /// text input. fn aria_value(mut self, value: impl Into) -> Self { - self.interactivity().aria_value = Some(value.into()); + self.interactivity().aria.value = Some(value.into()); self } /// Set the placeholder text reported to assistive technology for this /// element, shown when a text input is empty. fn aria_placeholder(mut self, placeholder: impl Into) -> Self { - self.interactivity().aria_placeholder = Some(placeholder.into()); + self.interactivity().aria.placeholder = Some(placeholder.into()); 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.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.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.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.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.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.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.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.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.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.interactivity().aria.column_count = Some(count); self } @@ -1898,6 +1917,30 @@ impl IntoElement for Div { } } +#[derive(Default)] +pub(crate) struct AriaProperties { + pub(crate) label: Option, + pub(crate) description: Option, + pub(crate) keyshortcuts: Option, + pub(crate) selected: Option, + pub(crate) expanded: Option, + pub(crate) toggled: Option, + pub(crate) numeric_value: Option, + pub(crate) min_numeric_value: Option, + pub(crate) max_numeric_value: Option, + pub(crate) numeric_value_step: Option, + pub(crate) value: Option, + pub(crate) placeholder: Option, + pub(crate) orientation: Option, + pub(crate) level: Option, + pub(crate) position_in_set: Option, + pub(crate) size_of_set: Option, + pub(crate) row_index: Option, + pub(crate) column_index: Option, + pub(crate) row_count: Option, + pub(crate) column_count: Option, +} + /// The interactivity struct. Powers all of the general-purpose /// interactivity in the `Div` element. #[derive(Default)] @@ -1963,24 +2006,7 @@ pub struct Interactivity { pub(crate) a11y_synthetic_children: Option>, pub(crate) report_active_descendant_focus: bool, 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_numeric_value_step: Option, - pub(crate) aria_value: Option, - pub(crate) aria_placeholder: 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, + pub(crate) aria: AriaProperties, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -2110,6 +2136,13 @@ impl Interactivity { if focus_handle.is_focused(window) { window.a11y.set_focus(node_id); } + } else if focus_handle.is_focused(window) { + // Focusable, but with no element id it can't have an + // accessibility node, so screen readers fall back to the + // whole window. + window + .a11y + .note_focus_without_node(focus_handle.id, "it has no element id"); } } } @@ -2297,9 +2330,6 @@ impl Interactivity { if self.tab_group { tab_group = self.tab_index; } - if let Some(focus_handle) = &self.tracked_focus_handle { - window.next_frame.tab_stops.insert(focus_handle); - } window.with_element_opacity(style.opacity, |window| { style.paint(bounds, window, cx, |window: &mut Window, cx: &mut App| { @@ -2308,6 +2338,17 @@ impl Interactivity { style.overflow_mask(bounds, window.rem_size()), |window| { window.with_tab_group(tab_group, |window| { + // Register the container's own focus handle *inside* its + // tab group, so that focusing the container and then + // calling `focus_next` descends into this group's first + // item. Inserting it before `with_tab_group` would give the + // container a shallower tab path than its children; with + // sibling groups every container would then sort ahead of + // every item, and `focus_next` from a container would jump + // to the first item in the whole window instead of its own. + if let Some(focus_handle) = &self.tracked_focus_handle { + window.next_frame.tab_stops.insert(focus_handle); + } if let Some(hitbox) = hitbox { #[cfg(debug_assertions)] self.paint_debug_info( @@ -3225,58 +3266,64 @@ impl Interactivity { } pub(crate) fn write_a11y_info(&self, node: &mut accesskit::Node) { - if let Some(label) = &self.aria_label { + if let Some(label) = &self.aria.label { node.set_label(label.to_string()); } - if let Some(selected) = self.aria_selected { + if let Some(description) = &self.aria.description { + node.set_description(description.to_string()); + } + if let Some(keyshortcuts) = &self.aria.keyshortcuts { + node.set_keyboard_shortcut(keyshortcuts.to_string()); + } + if let Some(selected) = self.aria.selected { node.set_selected(selected); } - if let Some(expanded) = self.aria_expanded { + if let Some(expanded) = self.aria.expanded { node.set_expanded(expanded); } - if let Some(toggled) = self.aria_toggled { + if let Some(toggled) = self.aria.toggled { node.set_toggled(toggled); } - if let Some(value) = self.aria_numeric_value { + if let Some(value) = self.aria.numeric_value { node.set_numeric_value(value); } - if let Some(value) = self.aria_min_numeric_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 { + if let Some(value) = self.aria.max_numeric_value { node.set_max_numeric_value(value); } - if let Some(step) = self.aria_numeric_value_step { + if let Some(step) = self.aria.numeric_value_step { node.set_numeric_value_step(step); } - if let Some(value) = &self.aria_value { + if let Some(value) = &self.aria.value { node.set_value(value.to_string()); } - if let Some(placeholder) = &self.aria_placeholder { + if let Some(placeholder) = &self.aria.placeholder { node.set_placeholder(placeholder.to_string()); } - if let Some(orientation) = self.aria_orientation { + if let Some(orientation) = self.aria.orientation { node.set_orientation(orientation); } - if let Some(level) = self.aria_level { + if let Some(level) = self.aria.level { node.set_level(level); } - if let Some(position) = self.aria_position_in_set { + if let Some(position) = self.aria.position_in_set { node.set_position_in_set(position); } - if let Some(size) = self.aria_size_of_set { + if let Some(size) = self.aria.size_of_set { node.set_size_of_set(size); } - if let Some(index) = self.aria_row_index { + if let Some(index) = self.aria.row_index { node.set_row_index(index); } - if let Some(index) = self.aria_column_index { + if let Some(index) = self.aria.column_index { node.set_column_index(index); } - if let Some(count) = self.aria_row_count { + if let Some(count) = self.aria.row_count { node.set_row_count(count); } - if let Some(count) = self.aria_column_count { + if let Some(count) = self.aria.column_count { node.set_column_count(count); } if !self.click_listeners.is_empty() { @@ -4379,13 +4426,13 @@ mod tests { #[test] fn test_write_a11y_info_string_and_numeric_properties() { let mut interactivity = Interactivity::default(); - interactivity.aria_label = Some("Buffer Font Size".into()); - interactivity.aria_value = Some("15".into()); - interactivity.aria_placeholder = Some("Search".into()); - interactivity.aria_numeric_value = Some(15.0); - interactivity.aria_min_numeric_value = Some(6.0); - interactivity.aria_max_numeric_value = Some(72.0); - interactivity.aria_numeric_value_step = Some(1.0); + interactivity.aria.label = Some("Buffer Font Size".into()); + interactivity.aria.value = Some("15".into()); + interactivity.aria.placeholder = Some("Search".into()); + interactivity.aria.numeric_value = Some(15.0); + interactivity.aria.min_numeric_value = Some(6.0); + interactivity.aria.max_numeric_value = Some(72.0); + interactivity.aria.numeric_value_step = Some(1.0); let mut node = accesskit::Node::new(accesskit::Role::SpinButton); interactivity.write_a11y_info(&mut node); @@ -4603,4 +4650,69 @@ mod tests { assert!(clicks.borrow().is_empty(), "clicks: {:?}", clicks.borrow()); } + + /// Two sibling tab groups, each a focusable container that is *not* itself a + /// tab stop and holds a single tab stop. Mirrors how the title bar and + /// status bar expose their controls as ARIA toolbars. + struct TabGroupFocus { + group_a: FocusHandle, + item_a: FocusHandle, + group_b: FocusHandle, + item_b: FocusHandle, + } + + impl Render for TabGroupFocus { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + fn group(container: &FocusHandle, item: &FocusHandle) -> Div { + div() + .track_focus(container) + .tab_group() + .child(div().track_focus(item)) + } + div() + .child(group(&self.group_a, &self.item_a)) + .child(group(&self.group_b, &self.item_b)) + } + } + + /// Focusing a tab-group container and pressing Tab (`focus_next`) must move + /// focus to the first tab stop *inside that container*, as documented on + /// [`InteractiveElement::tab_stop`]. + #[test] + fn focus_next_from_tab_group_container_enters_that_group() { + let mut cx = TestAppContext::single(); + let (group_a, item_a, group_b, item_b) = cx.update(|cx| { + ( + cx.focus_handle(), + cx.focus_handle().tab_stop(true), + cx.focus_handle(), + cx.focus_handle().tab_stop(true), + ) + }); + let window: AnyWindowHandle = cx + .add_window({ + let (group_a, item_a, group_b, item_b) = + (group_a, item_a, group_b.clone(), item_b.clone()); + move |_, _| TabGroupFocus { + group_a, + item_a, + group_b, + item_b, + } + }) + .into(); + cx.update_window(window, |_, window, cx| window.draw(cx).clear()) + .unwrap(); + + // Focus the *second* group's container, then advance like Tab would. + let focused = cx + .update_window(window, |_, window, cx| { + window.focus(&group_b, cx); + window.focus_next(cx); + window.focused(cx).map(|handle| handle.id) + }) + .unwrap(); + + assert_eq!(focused, Some(item_b.id)); + } } diff --git a/crates/gpui/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs index a205005963..bde651ae5c 100644 --- a/crates/gpui/src/tab_stop.rs +++ b/crates/gpui/src/tab_stop.rs @@ -196,6 +196,10 @@ impl TabStopMap { self.insertion_history.len() } + pub(crate) fn tab_stop_count(&self) -> usize { + self.by_id.values().filter(|node| node.tab_stop).count() + } + fn focus_handle_for_order(&self, order: &TabStopNode) -> Option { let handle = self.insertion_history[order.node_insertion_index].focus_handle(); debug_assert!( diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 394f00c108..f8343be29a 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -157,6 +157,13 @@ mod any_view { cx: &mut App, ) -> AnyElement { let view = view.clone().downcast::().unwrap(); + // Record the view's Render type name so the accessibility debug dump can + // attribute nodes to the view that produced them. + #[cfg(debug_assertions)] + window + .a11y + .view_type_names + .insert(view.entity_id(), std::any::type_name::()); view.update(cx, |view, cx| view.render(window, cx).into_any_element()) } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 249d703e98..1a5b018f5a 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2896,8 +2896,15 @@ impl Window { let should_send_a11y_update = a11y_active_start_of_frame && a11y_active_end_of_frame; if a11y_active_start_of_frame { + // Harvest frame metadata for the debug dump while the live window + // and frame are still in scope. + let frame_info = crate::window::a11y::debug::FrameDebugInfo { + viewport_size: self.viewport_size, + scale_factor: self.scale_factor, + tab_stop_count: self.next_frame.tab_stops.tab_stop_count(), + }; // clear the builder state regardless - let tree_update = self.a11y.end_frame(); + let tree_update = self.a11y.end_frame(frame_info); if should_send_a11y_update { log::debug!( @@ -5546,6 +5553,11 @@ impl Window { self.a11y.is_active() } + /// Debug representation of the last frame's accessibility information. + pub fn debug_a11y_tree_json(&self) -> Option { + self.a11y.debug_tree_json() + } + /// 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`. diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index 68140fd123..234e7f0acd 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -100,6 +100,8 @@ use crate::*; +pub(crate) mod debug; + use crate::{App, Bounds, FocusId, Pixels, SharedString, Window}; use accesskit::{Action, NodeId, TreeUpdate}; use collections::{FxHashMap, FxHashSet}; @@ -150,6 +152,15 @@ pub(crate) struct A11y { /// The window's title, used to label the root node so assistive /// technology can tell windows apart. window_title: Option, + /// The focus id we most recently reported as having no accessibility node, + /// used to log at most once per focus change rather than every frame. + last_focus_without_node: Option, + /// Retains the last tree update (and, in debug builds, per-node provenance) + /// so it can be dumped via [`crate::Window::debug_a11y_tree_json`]. + debug: debug::A11yDebug, + /// Maps a view's [`EntityId`] to its `Render` type name + #[cfg(debug_assertions)] + pub(crate) view_type_names: FxHashMap, } impl A11y { @@ -167,6 +178,26 @@ impl A11y { node_bounds: FxHashMap::default(), action_listeners: FxHashMap::default(), window_title, + last_focus_without_node: None, + debug: debug::A11yDebug::default(), + #[cfg(debug_assertions)] + view_type_names: FxHashMap::default(), + } + } + + /// Logs (once per focus change) that the focused element is not exposed to + /// assistive technology because it has no accessibility node. When this + /// happens, screen readers fall back to announcing the whole window instead + /// of the focused element. The fix is to give the element both an + /// `.id(...)` and a `.role(...)`. + pub(crate) fn note_focus_without_node(&mut self, focus_id: FocusId, reason: &str) { + if self.last_focus_without_node != Some(focus_id) { + self.last_focus_without_node = Some(focus_id); + log::info!( + "a11y: focused element ({focus_id:?}) has no accessibility node \ + ({reason}); assistive technology will announce the whole window \ + instead. Give it both an `.id(...)` and a `.role(...)` to expose it." + ); } } @@ -207,7 +238,16 @@ impl A11y { } } if self.nodes.has_node(node_id) { + // The focused element is properly exposed; reset the dedup so a + // later focus on a node-less element logs again. + self.last_focus_without_node = None; self.nodes.set_focus(node_id); + } else { + // The element registered a focus handle and an id, but never got a + // node because it has no role. + if let Some(focus_id) = self.focus_ids.get(&node_id).copied() { + self.note_focus_without_node(focus_id, "it has an id but no role"); + } } } @@ -236,8 +276,22 @@ impl A11y { } /// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter. - pub(crate) fn end_frame(&mut self) -> TreeUpdate { - self.nodes.finalize() + pub(crate) fn end_frame(&mut self, frame: debug::FrameDebugInfo) -> TreeUpdate { + let update = self.nodes.finalize(); + self.debug.capture( + &update, + self.nodes.focus, + self.nodes.active_descendant, + self.window_title.as_ref(), + frame, + ); + #[cfg(debug_assertions)] + self.debug.capture_node_info(&self.nodes.node_info); + update + } + + pub(crate) fn debug_tree_json(&self) -> Option { + self.debug.to_json() } } @@ -246,11 +300,26 @@ impl A11y { pub struct A11ySubtreeBuilder<'a> { parent_id: NodeId, nodes: &'a mut A11yNodeBuilder, + /// Provenance of the real element whose `a11y_synthetic_children` is + /// running. + #[cfg(debug_assertions)] + creator: debug::NodeCreator, } impl<'a> A11ySubtreeBuilder<'a> { pub(crate) fn new(parent_id: NodeId, nodes: &'a mut A11yNodeBuilder) -> Self { - Self { parent_id, nodes } + Self { + parent_id, + nodes, + #[cfg(debug_assertions)] + creator: debug::NodeCreator::default(), + } + } + + #[cfg(debug_assertions)] + pub(crate) fn with_creator(mut self, creator: debug::NodeCreator) -> Self { + self.creator = creator; + self } /// Derive a [`NodeId`] for a synthetic child. @@ -271,7 +340,20 @@ impl<'a> A11ySubtreeBuilder<'a> { /// Returns `false` if a node with this id is already present in the tree, /// in which case the node is discarded. pub fn push_child(&mut self, id: NodeId, node: accesskit::Node) -> bool { - self.nodes.push_leaf(id, node) + let pushed = self.nodes.push_leaf(id, node); + #[cfg(debug_assertions)] + if pushed { + self.nodes.record_node_info( + id, + debug::NodeDebugInfo { + synthetic: true, + view: self.creator.view, + element_id: self.creator.element_id.clone(), + source_location: self.creator.source_location, + }, + ); + } + pushed } /// A mutable reference to the parent node. @@ -297,6 +379,8 @@ pub(crate) struct A11yNodeBuilder { /// pattern, which allows a focused container to act as if a descendant is /// focused. active_descendant: Option, + #[cfg(debug_assertions)] + node_info: FxHashMap, } impl A11yNodeBuilder { @@ -308,9 +392,17 @@ impl A11yNodeBuilder { seen_ids: FxHashSet::default(), focus: None, active_descendant: None, + #[cfg(debug_assertions)] + node_info: FxHashMap::default(), } } + /// Records provenance for a node already pushed this frame. Debug builds only. + #[cfg(debug_assertions)] + pub(crate) fn record_node_info(&mut self, id: NodeId, info: debug::NodeDebugInfo) { + self.node_info.insert(id, info); + } + #[must_use] fn can_push(&mut self, id: NodeId) -> bool { debug_assert!(!self.ids_stack.is_empty(), "node pushed before push_root"); @@ -380,6 +472,8 @@ impl A11yNodeBuilder { self.ids_stack.clear(); self.nodes_stack.clear(); self.seen_ids.clear(); + #[cfg(debug_assertions)] + self.node_info.clear(); let mut root_node = accesskit::Node::new(accesskit::Role::Window); if let Some(title) = window_title { root_node.set_label(title.to_string()); @@ -788,7 +882,7 @@ mod tests { a11y.nodes.pop(); // c a11y.nodes.pop(); // b - let update = a11y.end_frame(); + let update = a11y.end_frame(Default::default()); assert_eq!(update.focus, a); } } diff --git a/crates/gpui/src/window/a11y/debug.rs b/crates/gpui/src/window/a11y/debug.rs new file mode 100644 index 0000000000..ae98aa5283 --- /dev/null +++ b/crates/gpui/src/window/a11y/debug.rs @@ -0,0 +1,330 @@ +//! Developer tooling for inspecting the accessibility tree. +//! +//! [`A11yDebug`] retains the last [`TreeUpdate`] sent to the platform adapter so +//! it can be serialized on demand (see +//! [`crate::Window::debug_a11y_tree_json`]). In `cfg(debug_assertions)` builds, +//! we capture extra info. + +use accesskit::{Action, NodeId, TreeUpdate}; +use collections::FxHashMap; + +use crate::{Pixels, SharedString, Size}; + +#[derive(Default)] +pub(crate) struct FrameDebugInfo { + pub viewport_size: Size, + pub scale_factor: f32, + pub tab_stop_count: usize, +} + +struct CapturedFrame { + rendered_at: String, + frame_number: u64, + window_title: Option, + node_count: usize, + tab_stop_count: usize, + viewport_size: Size, + scale_factor: f32, +} + +#[cfg(debug_assertions)] +#[derive(Clone, Default)] +pub(crate) struct NodeDebugInfo { + /// Whether the node was synthesized via + /// [`crate::Element::a11y_synthetic_children`] rather than created from a + /// real element with a role and ID. + pub synthetic: bool, + /// The type name of the `Render` view that was rendering when the node was + /// created. + pub view: Option<&'static str>, + /// The [`ElementId`](crate::ElementId) of the creating element (the leaf of + /// its `GlobalElementId`, not the full path). For a synthetic node, this is + /// the real element whose `a11y_synthetic_children` produced it. + pub element_id: Option, + /// Source location where the creating element was constructed. + pub source_location: Option<&'static core::panic::Location<'static>>, +} + +#[cfg(debug_assertions)] +#[derive(Clone, Default)] +pub(crate) struct NodeCreator { + pub view: Option<&'static str>, + pub element_id: Option, + pub source_location: Option<&'static core::panic::Location<'static>>, +} + +#[derive(Default)] +pub(crate) struct A11yDebug { + last_tree_update: Option, + last_gpui_focus: Option, + last_active_descendant: Option, + /// Monotonic counter incremented on each captured frame, so a re-dump makes + /// it obvious whether the tree actually refreshed. + frame_number: u64, + /// Metadata about the most recently captured frame. + last_frame: Option, + #[cfg(debug_assertions)] + last_node_info: FxHashMap, +} + +impl A11yDebug { + pub(crate) fn capture( + &mut self, + update: &TreeUpdate, + gpui_focus: Option, + active_descendant: Option, + window_title: Option<&SharedString>, + frame: FrameDebugInfo, + ) { + self.last_tree_update = Some(update.clone()); + self.last_gpui_focus = gpui_focus; + self.last_active_descendant = active_descendant; + self.frame_number += 1; + self.last_frame = Some(CapturedFrame { + rendered_at: chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, false), + frame_number: self.frame_number, + window_title: window_title.cloned(), + node_count: update.nodes.len(), + tab_stop_count: frame.tab_stop_count, + viewport_size: frame.viewport_size, + scale_factor: frame.scale_factor, + }); + } + + #[cfg(debug_assertions)] + pub(crate) fn capture_node_info(&mut self, node_info: &FxHashMap) { + self.last_node_info = node_info.clone(); + } + + /// Serialize the last tree update to a readable JSON string. Node ids are + /// replaced with short ephemeral ids (`a`, `b`, ..., `z`, `aa`, ...). + pub(crate) fn to_json(&self) -> Option { + let update = self.last_tree_update.as_ref()?; + + let mut ephemeral: FxHashMap = FxHashMap::default(); + for (index, (id, _)) in update.nodes.iter().enumerate() { + ephemeral.insert(*id, ephemeral_id(index)); + } + + let mut nodes = serde_json::Map::new(); + for (id, node) in &update.nodes { + let key = ephemeral + .get(id) + .cloned() + .unwrap_or_else(|| id.0.to_string()); + #[cfg(debug_assertions)] + let provenance = self + .last_node_info + .get(id) + .map(|info| NodeProvenance { + element_id: info.element_id.as_deref(), + view: info.view, + source_location: info.source_location.map(|loc| loc.to_string()), + // Only surface synthetic nodes; `false` is the default and + // would just be noise on every real node. + synthetic: info.synthetic.then_some(true), + }) + .unwrap_or_default(); + #[cfg(not(debug_assertions))] + let provenance = NodeProvenance::default(); + let value = node_to_json(*id, node, &ephemeral, &provenance); + nodes.insert(key, value); + } + + let frame = self.last_frame.as_ref().map(|frame| { + serde_json::json!({ + "rendered_at": frame.rendered_at, + "frame_number": frame.frame_number, + "window_title": frame.window_title.as_ref().map(|title| title.to_string()), + "node_count": frame.node_count, + "tab_stop_count": frame.tab_stop_count, + "viewport_size": { + "width": frame.viewport_size.width.0, + "height": frame.viewport_size.height.0, + }, + "scale_factor": frame.scale_factor, + }) + }); + + let root = update + .tree + .as_ref() + .map(|tree| tree.root) + .and_then(|id| ephemeral.get(&id).cloned()); + + let value = serde_json::json!({ + "root": root, + "gpui_focus": self.last_gpui_focus.and_then(|id| ephemeral.get(&id).cloned()), + "active_descendant_focus": self.last_active_descendant.and_then(|id| ephemeral.get(&id).cloned()), + "frame": frame, + "nodes": nodes, + }); + Some(serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())) + } +} + +#[derive(Default)] +struct NodeProvenance<'a> { + element_id: Option<&'a str>, + view: Option<&'a str>, + source_location: Option, + synthetic: Option, +} + +fn node_to_json( + id: NodeId, + node: &accesskit::Node, + ephemeral: &FxHashMap, + provenance: &NodeProvenance, +) -> serde_json::Value { + use serde_json::json; + + let mut map = serde_json::Map::new(); + map.insert("accesskit_id".into(), json!(id.0.to_string())); + + let children: Vec = node + .children() + .iter() + .map(|child| { + ephemeral + .get(child) + .cloned() + .unwrap_or_else(|| child.0.to_string()) + }) + .collect(); + if !children.is_empty() { + map.insert("children".into(), json!(children)); + } + + // Provenance (debug builds only), ordered before the accessibility section. + if let Some(element_id) = provenance.element_id { + map.insert("element_id".into(), json!(element_id)); + } + if let Some(view) = provenance.view { + map.insert("view".into(), json!(view)); + } + if let Some(source_location) = &provenance.source_location { + map.insert("source_location".into(), json!(source_location)); + } + if let Some(synthetic) = provenance.synthetic { + map.insert("synthetic".into(), json!(synthetic)); + } + + // Accessibility semantics for this node, grouped together. + let mut aria = serde_json::Map::new(); + aria.insert("role".into(), json!(format!("{:?}", node.role()))); + + // Which action types the node supports. AccessKit keeps these in a private + // bitset with no getter or iterator, so we probe each variant. `Action::n` + // (from AccessKit's `enumn` feature) maps a discriminant to its variant, + // returning `None` past the last one - so this can't drift out of sync with + // AccessKit's `Action` enum the way a hand-maintained list would. + let mut next_action = 0u8; + let on_action: Vec = std::iter::from_fn(move || { + let action = Action::n(next_action)?; + next_action += 1; + Some(action) + }) + .filter(|action| node.supports_action(*action)) + .map(|action| format!("{action:?}")) + .collect(); + if !on_action.is_empty() { + aria.insert("on_action".into(), json!(on_action)); + } + + // String properties. + if let Some(v) = node.label() { + aria.insert("label".into(), json!(v)); + } + if let Some(v) = node.description() { + aria.insert("description".into(), json!(v)); + } + if let Some(v) = node.value() { + aria.insert("value".into(), json!(v)); + } + if let Some(v) = node.keyboard_shortcut() { + aria.insert("keyboard_shortcut".into(), json!(v)); + } + if let Some(v) = node.access_key() { + aria.insert("access_key".into(), json!(v)); + } + if let Some(v) = node.placeholder() { + aria.insert("placeholder".into(), json!(v)); + } + if let Some(v) = node.tooltip() { + aria.insert("tooltip".into(), json!(v)); + } + if let Some(v) = node.role_description() { + aria.insert("role_description".into(), json!(v)); + } + + // Boolean / enum states. + if let Some(v) = node.is_selected() { + aria.insert("selected".into(), json!(v)); + } + if let Some(v) = node.is_expanded() { + aria.insert("expanded".into(), json!(v)); + } + if let Some(v) = node.toggled() { + aria.insert("toggled".into(), json!(format!("{v:?}"))); + } + if let Some(v) = node.orientation() { + aria.insert("orientation".into(), json!(format!("{v:?}"))); + } + + // Numeric properties. + if let Some(v) = node.numeric_value() { + aria.insert("numeric_value".into(), json!(v)); + } + if let Some(v) = node.min_numeric_value() { + aria.insert("min_numeric_value".into(), json!(v)); + } + if let Some(v) = node.max_numeric_value() { + aria.insert("max_numeric_value".into(), json!(v)); + } + if let Some(v) = node.numeric_value_step() { + aria.insert("numeric_value_step".into(), json!(v)); + } + + // Set / table properties. + if let Some(v) = node.level() { + aria.insert("level".into(), json!(v)); + } + if let Some(v) = node.position_in_set() { + aria.insert("position_in_set".into(), json!(v)); + } + if let Some(v) = node.size_of_set() { + aria.insert("size_of_set".into(), json!(v)); + } + if let Some(v) = node.row_index() { + aria.insert("row_index".into(), json!(v)); + } + if let Some(v) = node.column_index() { + aria.insert("column_index".into(), json!(v)); + } + if let Some(v) = node.row_count() { + aria.insert("row_count".into(), json!(v)); + } + if let Some(v) = node.column_count() { + aria.insert("column_count".into(), json!(v)); + } + + map.insert("aria".into(), serde_json::Value::Object(aria)); + + serde_json::Value::Object(map) +} + +/// Maps a 0-based index to a short id in the sequence `a, b, ..., z, aa, ab, +/// ...` (bijective base-26). +fn ephemeral_id(mut index: usize) -> String { + let mut bytes = Vec::new(); + loop { + bytes.push(b'a' + (index % 26) as u8); + if index < 26 { + break; + } + index = index / 26 - 1; + } + bytes.reverse(); + String::from_utf8(bytes).unwrap_or_default() +} diff --git a/crates/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index e9e6dc82ff..4694aa813d 100644 --- a/crates/language_selector/src/active_buffer_language.rs +++ b/crates/language_selector/src/active_buffer_language.rs @@ -53,8 +53,10 @@ impl Render for ActiveBufferLanguage { }; el.child( - Button::new("change-language", active_language_text) + Button::new("change-language", active_language_text.clone()) .label_size(LabelSize::Small) + .tab_index(0isize) + .aria_label(format!("Language: {active_language_text}")) .on_click(cx.listener(|this, _, window, cx| { if let Some(workspace) = this.workspace.upgrade() { workspace.update(cx, |workspace, cx| { diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 625e48b087..bbc8683a23 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -1415,6 +1415,8 @@ impl Render for LspButton { IconButton::new("zed-lsp-tool-button", IconName::BoltOutlined) .when_some(indicator, IconButton::indicator) .icon_size(IconSize::Small) + .tab_index(0isize) + .aria_label("Language Servers") .when(is_restricted, |s| s.icon_color(Color::Warning)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)), move |_window, cx| { diff --git a/crates/line_ending_selector/src/line_ending_indicator.rs b/crates/line_ending_selector/src/line_ending_indicator.rs index 419c63d0ac..cd97e1e54f 100644 --- a/crates/line_ending_selector/src/line_ending_indicator.rs +++ b/crates/line_ending_selector/src/line_ending_indicator.rs @@ -40,6 +40,7 @@ impl Render for LineEndingIndicator { el.child( Button::new("change-line-ending", line_ending.label()) .label_size(LabelSize::Small) + .tab_index(0isize) .on_click(cx.listener(|this, _, window, cx| { if let Some(editor) = this.active_editor.as_ref() { LineEndingSelector::toggle(editor, window, cx); diff --git a/crates/search/src/search_status_button.rs b/crates/search/src/search_status_button.rs index a31121e2f0..8f6e6fef32 100644 --- a/crates/search/src/search_status_button.rs +++ b/crates/search/src/search_status_button.rs @@ -30,6 +30,8 @@ impl Render for SearchButton { button.child( IconButton::new("project-search-indicator", SEARCH_ICON) .icon_size(IconSize::Small) + .tab_index(0isize) + .aria_label("Project Search") .tooltip(move |_window, cx| { if let Some(focus_handle) = &focus_handle { Tooltip::for_action_in( diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 0d2f551143..5829109797 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -1002,6 +1002,7 @@ impl VsCodeSettings { fn workspace_settings_content(&self) -> WorkspaceSettingsContent { WorkspaceSettingsContent { active_pane_modifiers: self.active_pane_modifiers(), + accessible_mode: None, text_rendering_mode: None, autosave: self.read_enum("files.autoSave", |s| match s { "off" => Some(AutosaveSetting::Off), diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index c52cda911d..c51ce9a87d 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -77,6 +77,11 @@ pub struct WorkspaceSettingsContent { /// /// Default: auto ("on" on macOS, "off" otherwise) pub when_closing_with_no_tabs: Option, + /// Whether to optimize Zed's interface for assistive technology such as + /// screen readers. + /// + /// Default: false + pub accessible_mode: Option, /// Whether to use the system provided dialogs for Open and Save As. /// When set to false, Zed will use the built-in keyboard-first pickers. /// diff --git a/crates/settings_ui/src/components/dropdown.rs b/crates/settings_ui/src/components/dropdown.rs index ca2faa486d..c3189129e9 100644 --- a/crates/settings_ui/src/components/dropdown.rs +++ b/crates/settings_ui/src/components/dropdown.rs @@ -20,6 +20,7 @@ where tab_index: Option, disabled: bool, aria_label: Option, + aria_description: Option, on_change: Rc, } @@ -43,6 +44,7 @@ where tab_index: None, disabled: false, aria_label: None, + aria_description: None, on_change: Rc::new(on_change), } } @@ -68,6 +70,13 @@ where self.aria_label = Some(label.into()); self } + + /// Sets the supplementary description announced by assistive technology + /// after the combobox's name, role, and value (e.g. a setting subtitle). + pub fn aria_description(mut self, description: impl Into) -> Self { + self.aria_description = Some(description.into()); + self + } } impl RenderOnce for EnumVariantDropdown @@ -114,6 +123,9 @@ where context_menu, ) .when_some(self.aria_label, |this, label| this.aria_label(label)) + .when_some(self.aria_description, |this, description| { + this.aria_description(description) + }) .disabled(self.disabled) .when_some(self.tab_index, |elem, tab_index| elem.tab_index(tab_index)) .trigger_size(ButtonSize::Medium) diff --git a/crates/settings_ui/src/components/input_field.rs b/crates/settings_ui/src/components/input_field.rs index 81b98c14fc..155d98b7b0 100644 --- a/crates/settings_ui/src/components/input_field.rs +++ b/crates/settings_ui/src/components/input_field.rs @@ -25,6 +25,7 @@ pub struct SettingsInputField { action_slot: Option, color: Option, aria_label: Option, + aria_description: Option, } impl SettingsInputField { @@ -49,6 +50,7 @@ impl SettingsInputField { action_slot: None, color: None, aria_label: None, + aria_description: None, } } @@ -116,6 +118,13 @@ impl SettingsInputField { self.aria_label = Some(label.into()); self } + + /// Sets the supplementary description announced by assistive technology + /// after the field's name, role, and value. + pub fn aria_description(mut self, description: impl Into) -> Self { + self.aria_description = Some(description.into()); + self + } } impl RenderOnce for SettingsInputField { @@ -211,6 +220,7 @@ impl RenderOnce for SettingsInputField { let aria_label = self .aria_label .or_else(|| self.placeholder.map(SharedString::new_static)); + let aria_description = self.aria_description; let (a11y_value, a11y_text_runs) = text_field_a11y_state(self.id.clone(), &editor, window, cx); @@ -221,6 +231,9 @@ impl RenderOnce for SettingsInputField { .id(self.id.clone()) .role(Role::TextInput) .when_some(aria_label, |this, label| this.aria_label(label)) + .when_some(aria_description, |this, description| { + this.aria_description(description) + }) .aria_value(a11y_value) .when_some(self.placeholder, |this, placeholder| { this.aria_placeholder(placeholder) diff --git a/crates/settings_ui/src/components/number_field.rs b/crates/settings_ui/src/components/number_field.rs index 011c517e95..0020104740 100644 --- a/crates/settings_ui/src/components/number_field.rs +++ b/crates/settings_ui/src/components/number_field.rs @@ -275,6 +275,7 @@ pub struct NumberField { on_change: Rc, tab_index: Option, aria_label: Option, + aria_description: Option, } impl NumberField { @@ -316,6 +317,7 @@ impl NumberField { on_change: Rc::new(|_, _, _| {}), tab_index: None, aria_label: None, + aria_description: None, } } @@ -350,6 +352,13 @@ impl NumberField { self } + /// Sets the supplementary description announced by assistive technology + /// after the field's name, role, and value. + pub fn aria_description(mut self, description: impl Into) -> Self { + self.aria_description = Some(description.into()); + self + } + fn sync_on_change_state(&self, cx: &mut App) { self.on_change_state .update(cx, |state, _| *state = Some(self.on_change.clone())); @@ -522,6 +531,9 @@ impl RenderOnce for NumberField { .when_some(self.aria_label.clone(), |this, label| { this.aria_label(label) }) + .when_some(self.aria_description.clone(), |this, description| { + this.aria_description(description) + }) .when_some(a11y_numeric_value(&self.value), |this, value| { this.aria_numeric_value(value) }) @@ -774,6 +786,10 @@ impl RenderOnce for NumberField { .when_some(self.aria_label.clone(), |this, label| { this.aria_label(label) }) + .when_some( + self.aria_description.clone(), + |this, description| this.aria_description(description), + ) .flex_1() .h_full() .track_focus(&focus_handle) diff --git a/crates/settings_ui/src/components/ollama_model_picker.rs b/crates/settings_ui/src/components/ollama_model_picker.rs index 46cb38f0fe..c8d4263862 100644 --- a/crates/settings_ui/src/components/ollama_model_picker.rs +++ b/crates/settings_ui/src/components/ollama_model_picker.rs @@ -160,6 +160,8 @@ pub fn render_ollama_model_picker( field: SettingField, file: SettingsUiFile, _metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, _window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -168,15 +170,20 @@ pub fn render_ollama_model_picker( .map(|m| m.0.clone().into()) .unwrap_or_else(|| "".into()); + let trigger_value: SharedString = if current_value.is_empty() { + "Select a model…".into() + } else { + current_value.clone() + }; + PopoverMenu::new("ollama-model-picker") - .trigger(render_picker_trigger_button( - "ollama_model_picker_trigger".into(), - if current_value.is_empty() { - "Select a model…".into() - } else { - current_value.clone() - }, - )) + .trigger( + render_picker_trigger_button("ollama_model_picker_trigger".into(), trigger_value) + .aria_label(title) + .when(!description.is_empty(), |this| { + this.aria_description(description) + }), + ) .menu(move |window, cx| { Some(cx.new(|cx| { let file = file.clone(); diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 2f6ce0beec..0cb49694cb 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -139,6 +139,20 @@ fn general_page(cx: &App) -> SettingsPage { fn general_settings_section(_cx: &App) -> Vec { vec![ SettingsPageItem::SectionHeader("General Settings"), + SettingsPageItem::SettingItem(SettingItem { + title: "Accessible Mode", + description: "Optimize Zed's interface for assistive technology such as screen readers. When enabled, otherwise-collapsed controls stay expanded and keyboard-reachable.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("accessible_mode"), + pick: |settings_content| settings_content.workspace.accessible_mode.as_ref(), + write: |settings_content, value, _| { + settings_content.workspace.accessible_mode = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "When Closing With No Tabs", description: "What to do when using the 'close active item' action with no tabs.", diff --git a/crates/settings_ui/src/pages/audio_input_output_setup.rs b/crates/settings_ui/src/pages/audio_input_output_setup.rs index 5730dd6fa2..3381792d32 100644 --- a/crates/settings_ui/src/pages/audio_input_output_setup.rs +++ b/crates/settings_ui/src/pages/audio_input_output_setup.rs @@ -3,7 +3,7 @@ use cpal::DeviceId; use gpui::{AnyElement, App, ElementId, ReadGlobal, SharedString, Window}; use settings::{AudioInputDeviceName, AudioOutputDeviceName, SettingsStore}; use std::str::FromStr; -use ui::{ContextMenu, DropdownMenu, DropdownStyle, IconPosition, IntoElement}; +use ui::{ContextMenu, DropdownMenu, DropdownStyle, FluentBuilder, IconPosition, IntoElement}; use util::ResultExt; use crate::{SettingField, SettingsFieldMetadata, SettingsUiFile, update_settings_file}; @@ -29,6 +29,8 @@ pub(crate) fn render_audio_device_dropdown( current_device_id: Option, is_input: bool, on_select: F, + aria_label: Option, + aria_description: Option, window: &mut Window, cx: &mut App, ) -> AnyElement @@ -89,6 +91,10 @@ where ) .style(DropdownStyle::Outlined) .full_width(true) + .when_some(aria_label, |this, label| this.aria_label(label)) + .when_some(aria_description, |this, description| { + this.aria_description(description) + }) .into_any_element() } @@ -96,6 +102,8 @@ fn render_settings_audio_device_dropdown> + From, file: SettingsUiFile, is_input: bool, + title: &'static str, + description: &'static str, window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -127,6 +135,8 @@ fn render_settings_audio_device_dropdown> + From, file: SettingsUiFile, _metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, window: &mut Window, cx: &mut App, ) -> AnyElement { - render_settings_audio_device_dropdown(field, file, true, window, cx) + render_settings_audio_device_dropdown(field, file, true, title, description, window, cx) } pub fn render_output_audio_device_dropdown( field: SettingField, file: SettingsUiFile, _metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, window: &mut Window, cx: &mut App, ) -> AnyElement { - render_settings_audio_device_dropdown(field, file, false, window, cx) + render_settings_audio_device_dropdown(field, file, false, title, description, window, cx) } diff --git a/crates/settings_ui/src/pages/audio_test_window.rs b/crates/settings_ui/src/pages/audio_test_window.rs index d50d017d7a..060a1c0d35 100644 --- a/crates/settings_ui/src/pages/audio_test_window.rs +++ b/crates/settings_ui/src/pages/audio_test_window.rs @@ -171,6 +171,8 @@ impl Render for AudioTestWindow { ) .log_err(); }, + None, + None, window, cx, ) @@ -200,6 +202,8 @@ impl Render for AudioTestWindow { ) .log_err(); }, + None, + None, window, cx, ); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 96ebc5f34c..45901486a1 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -15,7 +15,6 @@ use gpui::{ WindowBounds, WindowHandle, WindowOptions, actions, div, list, point, prelude::*, px, uniform_list, }; -use heck::ToTitleCase as _; use language::Buffer; use platform_title_bar::PlatformTitleBar; @@ -312,6 +311,8 @@ impl SettingFieldRenderer { SettingField, SettingsUiFile, Option<&SettingsFieldMetadata>, + &'static str, + &'static str, &mut Window, &mut App, ) -> AnyElement @@ -326,14 +327,16 @@ impl SettingFieldRenderer { sub_field: bool, window: &mut Window, cx: &mut Context| { - render_settings_item( - settings_window, - item, + let control = render_control( + field, settings_file.clone(), - render_control(field, settings_file, metadata, window, cx), - sub_field, + metadata, + item.title, + item.description, + window, cx, - ) + ); + render_settings_item(settings_window, item, settings_file, control, sub_field, cx) }, ) } @@ -1384,10 +1387,12 @@ fn render_settings_item_layout( sub_field: bool, cx: &mut Context<'_, SettingsWindow>, ) -> Stateful
{ + // Note: the row itself is intentionally not exposed as a labeled group. + // Each control names and describes itself (via the setting title and + // description), so adding a group with the same label here would make + // screen readers announce the setting name twice. h_flex() .id(title) - .role(Role::Group) - .aria_label(SharedString::new_static(title)) .min_w_0() .justify_between() .child( @@ -4804,10 +4809,6 @@ fn update_project_setting_file( /// Derives a human-readable label for assistive technology from a setting's /// JSON path, e.g. `"buffer_font_size"` becomes `"Buffer Font Size"`. -fn a11y_label_for_json_path(json_path: Option<&'static str>) -> Option { - json_path.map(|path| SharedString::from(path.to_title_case())) -} - struct CurrentSettingsValue<'a, T> { value: &'a T, disabled: bool, @@ -4839,6 +4840,8 @@ fn render_text_field + Into + AsRef + Clone>( field: SettingField, file: SettingsUiFile, metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, _window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -4860,10 +4863,10 @@ fn render_text_field + Into + AsRef + Clone>( // it a stable, collision-free element ID within the page. SettingsInputField::new(field.json_path.unwrap_or("settings-text-field")) .tab_index(0) - .when_some( - a11y_label_for_json_path(field.json_path), - |editor, label| editor.aria_label(label), - ) + .aria_label(title) + .when(!description.is_empty(), |editor| { + editor.aria_description(description) + }) .when_some(initial_text, |editor, text| editor.with_initial_text(text)) .when_some( metadata.and_then(|metadata| metadata.placeholder), @@ -4902,6 +4905,8 @@ fn render_toggle_button + From + Copy>( field: SettingField, file: SettingsUiFile, _metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, _window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -4918,8 +4923,9 @@ fn render_toggle_button + From + Copy>( Switch::new("toggle_button", toggle_state) .tab_index(0_isize) - .when_some(a11y_label_for_json_path(field.json_path), |this, label| { - this.aria_label(label) + .aria_label(title) + .when(!description.is_empty(), |this| { + this.aria_description(description) }) .disabled(disabled) .on_click({ @@ -4940,6 +4946,8 @@ fn render_editable_number_field( field: SettingField, file: SettingsUiFile, _metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -4954,8 +4962,9 @@ fn render_editable_number_field( NumberField::new(id, value, window, cx) .mode(NumberFieldMode::Edit, cx) .tab_index(0_isize) - .when_some(a11y_label_for_json_path(field.json_path), |this, label| { - this.aria_label(label) + .aria_label(title) + .when(!description.is_empty(), |this| { + this.aria_description(description) }) .on_change({ move |value, window, cx| { @@ -4979,6 +4988,8 @@ fn render_dropdown( field: SettingField, file: SettingsUiFile, metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, _window: &mut Window, cx: &mut App, ) -> AnyElement @@ -5013,8 +5024,9 @@ where .log_err(); // todo(settings_ui) don't log err } }) - .when_some(a11y_label_for_json_path(field.json_path), |this, label| { - this.aria_label(label) + .aria_label(title) + .when(!description.is_empty(), |this| { + this.aria_description(description) }) .disabled(disabled) .tab_index(0) @@ -5057,6 +5069,8 @@ fn render_font_picker( field: SettingField, file: SettingsUiFile, _metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, _window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -5073,8 +5087,9 @@ fn render_font_picker( "font_family_picker_trigger".into(), current_value.clone(), ) - .when_some(a11y_label_for_json_path(field.json_path), |this, label| { - this.aria_label(format!("{}: {}", label, current_value.clone())) + .aria_label(title) + .when(!description.is_empty(), |this| { + this.aria_description(description) }), handle.clone(), )) @@ -5115,6 +5130,8 @@ fn render_theme_picker( field: SettingField, file: SettingsUiFile, _metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, _window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -5128,8 +5145,9 @@ fn render_theme_picker( PopoverMenu::new("theme-picker") .trigger(wire_picker_trigger_a11y( render_picker_trigger_button("theme_picker_trigger".into(), current_value.clone()) - .when_some(a11y_label_for_json_path(field.json_path), |this, label| { - this.aria_label(format!("{}: {}", label, current_value.clone())) + .aria_label(title) + .when(!description.is_empty(), |this| { + this.aria_description(description) }), handle.clone(), )) @@ -5173,6 +5191,8 @@ fn render_icon_theme_picker( field: SettingField, file: SettingsUiFile, _metadata: Option<&SettingsFieldMetadata>, + title: &'static str, + description: &'static str, _window: &mut Window, cx: &mut App, ) -> AnyElement { @@ -5186,8 +5206,9 @@ fn render_icon_theme_picker( PopoverMenu::new("icon-theme-picker") .trigger(wire_picker_trigger_a11y( render_picker_trigger_button("icon_theme_picker_trigger".into(), current_value.clone()) - .when_some(a11y_label_for_json_path(field.json_path), |this, label| { - this.aria_label(format!("{}: {}", label, current_value.clone())) + .aria_label(title) + .when(!description.is_empty(), |this| { + this.aria_description(description) }), handle.clone(), )) diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 5429686ebd..369f232441 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -1,5 +1,6 @@ -use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, actions}; -use settings::Settings; +use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, Subscription, actions}; +use settings::{Settings, SettingsStore}; +use workspace::AccessibleMode; use schemars::JsonSchema; use serde::Deserialize; @@ -45,11 +46,15 @@ struct MenuEntry { pub struct ApplicationMenu { entries: SmallVec<[MenuEntry; 8]>, pending_menu_open: Option, + _settings_subscription: Subscription, } impl ApplicationMenu { pub fn new(_: &mut Window, cx: &mut Context) -> Self { let menus = cx.get_menus().unwrap_or_default(); + // Re-render when settings change so toggling "accessible mode" expands or + // collapses the menu bar (see `all_menus_shown`). + let settings_subscription = cx.observe_global::(|_, cx| cx.notify()); Self { entries: menus .into_iter() @@ -59,6 +64,7 @@ impl ApplicationMenu { }) .collect(), pending_menu_open: None, + _settings_subscription: settings_subscription, } } @@ -168,6 +174,7 @@ impl ApplicationMenu { ) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) + .tab_index(0isize) .aria_label("Application menu"), Tooltip::text("Open Application Menu"), ) @@ -201,7 +208,8 @@ impl ApplicationMenu { menu_name, ) .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small), + .label_size(LabelSize::Small) + .tab_index(0isize), ) .with_handle(current_handle.clone()), ) @@ -238,6 +246,12 @@ impl ApplicationMenu { .iter() .position(|entry| entry.handle.is_deployed()); let Some(current_index) = current_index else { + // No menu is open, so there is nothing to switch between. Let the + // arrow key continue to the title bar's toolbar navigation so it + // can move focus between title bar controls. Without this, the + // `left`/`right` bindings in the `ApplicationMenu` context would + // silently swallow the key as a no-op. + cx.propagate(); return; }; @@ -269,6 +283,9 @@ impl ApplicationMenu { show_menus(cx) || self.entries.iter().any(|entry| entry.handle.is_deployed()) || self.pending_menu_open.is_some() + // In accessible mode, keep the full menu bar expanded so every menu + // is individually reachable and labeled for assistive technology. + || cx.accessible_mode() } } diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 8ccc710dbf..f7ccc57ae3 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -52,7 +52,7 @@ use ui::{ use update_version::UpdateVersion; use util::ResultExt; use workspace::{ - MultiWorkspace, ToggleWorktreeSecurity, Workspace, + AccessibleMode, MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::{NotifyResultExt, NotifyTaskExt as _}, }; @@ -300,8 +300,13 @@ impl Render for TitleBar { .when_some( self.application_menu.clone().filter(|_| !show_menus), |title_bar, menu| { - render_project_items &= - !menu.update(cx, |menu, cx| menu.all_menus_shown(cx)); + // Hide the project/branch items to make room when the + // menu bar is expanded -- except in accessible mode, + // where the menu bar is always expanded but those + // controls must still remain reachable. + render_project_items &= !menu + .update(cx, |menu, cx| menu.all_menus_shown(cx)) + || cx.accessible_mode(); title_bar.child(menu) }, ) @@ -758,6 +763,7 @@ impl TitleBar { Button::new("project_owner_trigger", host_user.username.clone()) .color(Color::Player(participant_index.0)) .label_size(LabelSize::Small) + .tab_index(0isize) .tooltip(move |_, cx| { let tooltip_title = format!( "{} is sharing this project. Click to follow.", @@ -843,6 +849,7 @@ impl TitleBar { .trigger_with_tooltip( Button::new("project_name_trigger", display_name) .label_size(LabelSize::Small) + .tab_index(0isize) .when(self.worktree_count(cx) > 1, |this| { this.end_icon( Icon::new(IconName::ChevronDown) @@ -894,6 +901,7 @@ impl TitleBar { .trigger_with_tooltip( Button::new("project_name_trigger", display_name) .label_size(LabelSize::Small) + .tab_index(0isize) .when(self.worktree_count(cx) > 1, |this| { this.end_icon( Icon::new(IconName::ChevronDown) @@ -997,6 +1005,7 @@ impl TitleBar { .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .label_size(LabelSize::Small) .color(Color::Muted) + .tab_index(0isize) .loading(is_creating) .start_icon( Icon::new(IconName::GitWorktree) @@ -1028,6 +1037,7 @@ impl TitleBar { Button::new("project_branch_trigger", "Create Branch") .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .label_size(LabelSize::Small) + .tab_index(0isize) .start_icon( Icon::new(IconName::GitBranchPlus) .size(IconSize::XSmall) @@ -1038,6 +1048,7 @@ impl TitleBar { .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .label_size(LabelSize::Small) .color(Color::Muted) + .tab_index(0isize) .start_icon( Icon::new(branch_icon) .size(IconSize::XSmall) @@ -1194,6 +1205,7 @@ impl TitleBar { let workspace = self.workspace.clone(); Button::new("sign_in", "Sign In") .label_size(LabelSize::Small) + .tab_index(0isize) .on_click(move |_, window, cx| { let client = client.clone(); let workspace = workspace.clone(); @@ -1251,17 +1263,21 @@ impl TitleBar { } }); - ButtonLike::new("user-menu").aria_label("User menu").child( - h_flex() - .when_some(business_organization, |this, organization| { - this.gap_2() - .child(Label::new(&organization.name).size(LabelSize::Small)) - }) - .children(avatar), - ) + ButtonLike::new("user-menu") + .aria_label("User menu") + .tab_index(0isize) + .child( + h_flex() + .when_some(business_organization, |this, organization| { + this.gap_2() + .child(Label::new(&organization.name).size(LabelSize::Small)) + }) + .children(avatar), + ) } else { ButtonLike::new("user-menu") .aria_label("User menu") + .tab_index(0isize) .child(Icon::new(IconName::ChevronDown).size(IconSize::Small)) }; diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index 72c24df92e..7f157e2689 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -240,6 +240,7 @@ impl Render for ActiveToolchain { div().child( Button::new("change-toolchain", active_toolchain.name.clone()) .label_size(LabelSize::Small) + .tab_index(0isize) .on_click(cx.listener(|this, _, window, cx| { if let Some(workspace) = this.workspace.upgrade() { workspace.update(cx, |workspace, cx| { diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 094c0988ba..e8e55836c4 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -130,6 +130,21 @@ impl Button { self } + /// Sets the supplementary description announced by assistive technology + /// after the button's name, role, and value. + pub fn aria_description(mut self, description: impl Into) -> Self { + self.base = self.base.aria_description(description); + self + } + + /// Sets the current value reported to assistive technology. Use this when + /// the button represents a control with a value, such as a combobox + /// trigger whose value is the current selection. + pub fn aria_value(mut self, value: impl Into) -> Self { + self.base = self.base.aria_value(value); + self + } + /// Overrides the role reported to assistive technology. /// Defaults to [`gpui::Role::Button`]. pub fn aria_role(mut self, role: gpui::Role) -> Self { @@ -401,7 +416,7 @@ impl ButtonCommon for Button { impl RenderOnce for Button { #[allow(refining_impl_trait)] - fn render(mut self, _window: &mut Window, cx: &mut App) -> ButtonLike { + fn render(mut self, window: &mut Window, cx: &mut App) -> ButtonLike { let is_disabled = self.base.disabled; let is_selected = self.base.selected; @@ -414,6 +429,27 @@ impl RenderOnce for Button { self.base.aria_label = Some(label.clone()); } + // Announce the displayed keybinding to assistive technology via + // `aria-keyshortcuts` so screen-reader users hear the same shortcut + // sighted users see next to the button. + if self.base.aria_keyshortcuts.is_none() + && let Some(keyshortcuts) = self + .key_binding + .as_ref() + .and_then(|key_binding| key_binding.keyboard_shortcut_text(window, cx)) + { + self.base.aria_keyshortcuts = Some(keyshortcuts); + } + + // A combobox's value is its visible text, so expose it as the + // accessible value by default. This lets combobox triggers announce + // their current selection without each caller wiring it up. + if matches!(self.base.aria_role, Some(gpui::Role::ComboBox)) + && self.base.aria_value.is_none() + { + self.base.aria_value = Some(label.clone()); + } + let label_color = if is_disabled { Color::Disabled } else if is_selected { diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 3436b8dca5..20bcaf7fb6 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -493,7 +493,10 @@ pub struct ButtonLike { size: ButtonSize, rounding: Option, pub(super) aria_label: Option, - aria_role: Option, + aria_description: Option, + pub(super) aria_value: Option, + pub(super) aria_keyshortcuts: Option, + pub(super) aria_role: Option, aria_expanded: Option, toggled: Option, tooltip: Option AnyView>>, @@ -523,6 +526,9 @@ impl ButtonLike { size: ButtonSize::Default, rounding: Some(ButtonLikeRounding::ALL), aria_label: None, + aria_description: None, + aria_value: None, + aria_keyshortcuts: None, aria_role: None, aria_expanded: None, toggled: None, @@ -588,6 +594,32 @@ impl ButtonLike { self } + /// Sets the keyboard shortcut announced by assistive technology for this + /// button. Use a human-friendly display string (the accelerator shown to + /// sighted users), e.g. `"Ctrl-S"` - see + /// [`KeyBinding::keyboard_shortcut_text`](crate::KeyBinding::keyboard_shortcut_text). + /// [`Button`](crate::Button) sets this automatically from its displayed + /// keybinding. + pub fn aria_keyshortcuts(mut self, keyshortcuts: impl Into) -> Self { + self.aria_keyshortcuts = Some(keyshortcuts.into()); + self + } + + /// Sets the supplementary description announced by assistive technology + /// after the button's name, role, and value. + pub fn aria_description(mut self, description: impl Into) -> Self { + self.aria_description = Some(description.into()); + self + } + + /// Sets the current value reported to assistive technology. Use this when + /// the button represents a control with a value, such as a combobox + /// trigger whose value is the current selection. + pub fn aria_value(mut self, value: impl Into) -> Self { + self.aria_value = Some(value.into()); + self + } + /// Overrides the role reported to assistive technology. /// Defaults to [`Role::Button`]. pub fn aria_role(mut self, role: Role) -> Self { @@ -727,6 +759,13 @@ impl RenderOnce for ButtonLike { .id(self.id.clone()) .role(self.aria_role.unwrap_or(Role::Button)) .when_some(self.aria_label, |this, label| this.aria_label(label)) + .when_some(self.aria_keyshortcuts, |this, keyshortcuts| { + this.aria_keyshortcuts(keyshortcuts) + }) + .when_some(self.aria_description, |this, description| { + this.aria_description(description) + }) + .when_some(self.aria_value, |this, value| this.aria_value(value)) .when_some(self.aria_expanded, |this, expanded| { this.aria_expanded(expanded) }) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 2a3388a406..3736a2e320 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -296,6 +296,19 @@ impl ContextMenu { ); window.refresh(); + // When the menu first receives focus (i.e. when it opens), move the + // selection onto a menu item so assistive technology announces a real + // item rather than the bare menu container. Per the ARIA menu button + // pattern, opening a menu places focus on a menu item; for select-style + // menus we prefer the currently-checked item. We only do this when + // nothing is selected yet so we don't override an existing selection. + cx.on_focus_in(&focus_handle, window, |this, window, cx| { + if this.selected_index.is_none() { + this.select_toggled_or_first(window, cx); + } + }) + .detach(); + f( Self { builder: None, @@ -373,6 +386,15 @@ impl ContextMenu { ); window.refresh(); + // See the note in `ContextMenu::new`: select an item when the menu + // opens so screen readers announce it instead of just "menu". + cx.on_focus_in(&focus_handle, window, |this, window, cx| { + if this.selected_index.is_none() { + this.select_toggled_or_first(window, cx); + } + }) + .detach(); + (builder.clone())( Self { builder: Some(builder), @@ -1070,6 +1092,32 @@ impl ContextMenu { cx.notify(); } + /// Selects the currently-checked entry if one exists (e.g. the active value + /// in a single-select dropdown), otherwise the first selectable item. + /// + /// This is intended to be called when the menu opens. Per the ARIA menu + /// button pattern, opening a menu should place focus on a menu item rather + /// than the menu container, so that assistive technology immediately + /// announces a meaningful item (ideally the current selection) instead of + /// just "menu". + pub fn select_toggled_or_first(&mut self, window: &mut Window, cx: &mut Context) { + let toggled_ix = self.items.iter().position(|item| { + matches!( + item, + ContextMenuItem::Entry(ContextMenuEntry { + toggle: Some((_, true)), + .. + }) + ) + }); + if let Some(ix) = toggled_ix { + self.select_index(ix, window, cx); + cx.notify(); + } else { + self.select_first(&SelectFirst, window, cx); + } + } + pub fn select_last(&mut self, window: &mut Window, cx: &mut Context) -> Option { for (ix, item) in self.items.iter().enumerate().rev() { if item.is_selectable() { @@ -1440,7 +1488,7 @@ impl ContextMenu { .child(Label::new(label.clone())) .into_any_element(), ContextMenuItem::Entry(entry) => self - .render_menu_entry(ix, entry, is_active_descendant(true), cx) + .render_menu_entry(ix, entry, is_active_descendant(true), window, cx) .into_any_element(), ContextMenuItem::CustomEntry { entry_render, @@ -1757,6 +1805,7 @@ impl ContextMenu { ix: usize, entry: &ContextMenuEntry, is_active_descendant: bool, + window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let ContextMenuEntry { @@ -1779,6 +1828,17 @@ impl ContextMenu { secondary_handler: _, } = entry; let this = cx.weak_entity(); + // Report the item's keyboard shortcut to assistive technology, resolving + // the action's binding the same way the visible accelerator (rendered + // below) is. + let keyboard_shortcut = action.as_ref().and_then(|action| { + let binding = self + .action_context + .as_ref() + .map(|focus| KeyBinding::for_action_in(&**action, focus, cx)) + .unwrap_or_else(|| KeyBinding::for_action(&**action, cx)); + binding.keyboard_shortcut_text(window, cx) + }); let handler = handler.clone(); let menu = cx.entity().downgrade(); @@ -1902,8 +1962,12 @@ impl ContextMenu { } else { Role::MenuItem }) + .when_some(*toggle, |item, (_, checked)| item.aria_checked(checked)) .when(is_active_descendant, |item| item.aria_active_descendant()) .aria_label(label.clone()) + .when_some(keyboard_shortcut, |item, keyboard_shortcut| { + item.aria_keyshortcuts(keyboard_shortcut) + }) .toggle_state(Some(ix) == self.selected_index) .when(self.main_menu.is_none() && !*disabled, |item| { item.on_hover(cx.listener(move |this, hovered, window, cx| { diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index 3be19d333f..8991f99296 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -35,6 +35,8 @@ pub struct DropdownMenu { tab_index: Option, chevron: bool, aria_label: Option, + aria_description: Option, + aria_value: Option, } impl DropdownMenu { @@ -59,6 +61,8 @@ impl DropdownMenu { tab_index: None, chevron: true, aria_label: None, + aria_description: None, + aria_value: None, } } @@ -83,6 +87,8 @@ impl DropdownMenu { tab_index: None, chevron: true, aria_label: None, + aria_description: None, + aria_value: None, } } @@ -147,6 +153,20 @@ impl DropdownMenu { self.aria_label = Some(label.into()); self } + + /// Sets the supplementary description announced by assistive technology + /// after the combobox's name, role, and value. + pub fn aria_description(mut self, description: impl Into) -> Self { + self.aria_description = Some(description.into()); + self + } + + /// Sets the current value announced by assistive technology (the selected + /// option). Defaults to the trigger's visible text label. + pub fn aria_value(mut self, value: impl Into) -> Self { + self.aria_value = Some(value.into()); + self + } } impl Disableable for DropdownMenu { @@ -173,6 +193,15 @@ impl RenderOnce for DropdownMenu { let handle = self.handle.unwrap_or_default(); let expanded = handle.is_deployed(); + // A combobox should announce its current value (the selected option). + // Default to the trigger's visible text label when no explicit value + // is provided. + let aria_value = self.aria_value.clone().or_else(|| match &self.label { + LabelKind::Text(text) => Some(text.clone()), + LabelKind::Element(_) => None, + }); + let aria_description = self.aria_description.clone(); + let a11y_actions = |button: Button| { let show_handle = handle.clone(); let hide_handle = handle.clone(); @@ -202,6 +231,10 @@ impl RenderOnce for DropdownMenu { a11y_actions(Button::new(self.id.clone(), text)) .aria_role(Role::ComboBox) .when_some(self.aria_label, |this, label| this.aria_label(label)) + .when_some(aria_description, |this, description| { + this.aria_description(description) + }) + .when_some(aria_value, |this, value| this.aria_value(value)) .aria_expanded(expanded) .style(button_style) .when_some(self.trigger_icon.filter(|_| self.chevron), |this, icon| { @@ -222,6 +255,10 @@ impl RenderOnce for DropdownMenu { a11y_actions_element(ButtonLike::new(self.id.clone())) .aria_role(Role::ComboBox) .when_some(self.aria_label, |this, label| this.aria_label(label)) + .when_some(aria_description, |this, description| { + this.aria_description(description) + }) + .when_some(aria_value, |this, value| this.aria_value(value)) .aria_expanded(expanded) .child(element) .style(button_style) @@ -240,9 +277,19 @@ impl RenderOnce for DropdownMenu { ), }; + // When the menu opens, move the selection to the current value (or the + // first item) so assistive technology announces a meaningful item + // immediately, instead of focusing the bare menu container and + // announcing only "menu". See the ARIA menu button pattern. + let menu_for_open = self.menu.clone(); let mut popover = PopoverMenu::new((self.id.clone(), "popover")) .full_width(self.full_width) .with_handle(handle) + .on_open(std::rc::Rc::new(move |window, cx| { + menu_for_open.update(cx, |menu, cx| { + menu.select_toggled_or_first(window, cx); + }); + })) .menu(move |_window, _cx| Some(self.menu.clone())); popover = match (text_button, element_button, self.trigger_tooltip) { diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index af30dd6865..bc8c68067e 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -135,6 +135,50 @@ impl KeyBinding { self.vim_mode = vim_mode; self } + + /// Resolves this keybinding's keystrokes the same way rendering does + /// (matching the visible accelerator). Returns `None` when there is no + /// binding. + fn resolve_keystrokes(&self, window: &Window, cx: &App) -> Option> { + let keystrokes = match &self.source { + Source::Action { + action, + focus_handle, + } => { + let binding = focus_handle + .clone() + .or_else(|| window.focused(cx)) + .and_then(|focus| { + window.highest_precedence_binding_for_action_in(action.as_ref(), &focus) + }) + .or_else(|| window.highest_precedence_binding_for_action(action.as_ref()))?; + binding.keystrokes().to_vec() + } + Source::Keystrokes { keystrokes } => keystrokes.to_vec(), + }; + (!keystrokes.is_empty()).then_some(keystrokes) + } + + /// Resolves this keybinding to a human-readable shortcut string in the same + /// platform format shown to sighted users (e.g. `"Ctrl-S"`, `"Command-S"`, + /// or `"Ctrl-K Ctrl-S"` for a chord), matching the visible accelerator. This + /// is the format AccessKit's `keyboard_shortcut` property expects. Returns + /// `None` when there is no binding. + pub fn keyboard_shortcut_text(&self, window: &Window, cx: &App) -> Option { + let keystrokes = self.resolve_keystrokes(window, cx)?; + let text = keystrokes + .iter() + .map(|keystroke| { + keystroke_text( + keystroke.modifiers(), + keystroke.key(), + self.platform_style, + self.vim_mode, + ) + }) + .join(" "); + Some(text.into()) + } } fn render_key( diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 7077aea4c7..f6a37ee052 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -55,6 +55,8 @@ pub struct ListItem { height: Option, aria_role: Option, aria_label: Option, + aria_keyshortcuts: Option, + aria_checked: Option, aria_active_descendant: bool, } @@ -89,6 +91,8 @@ impl ListItem { height: None, aria_role: None, aria_label: None, + aria_keyshortcuts: None, + aria_checked: None, aria_active_descendant: false, } } @@ -109,6 +113,24 @@ impl ListItem { self } + /// Sets the keyboard shortcut announced by assistive technology for this + /// item, e.g. a menu item's accelerator. Requires [`Self::aria_role`] to be + /// set. Use a human-friendly display string (the accelerator shown to + /// sighted users), e.g. `"Ctrl-S"`. + pub fn aria_keyshortcuts(mut self, keyshortcuts: impl Into) -> Self { + self.aria_keyshortcuts = Some(keyshortcuts.into()); + self + } + + /// Sets the checked state reported to assistive technology, independent of + /// the visual disclosure [`Self::toggle`]. Use this for checkable items + /// such as a [`Role::MenuItemCheckBox`] entry so screen readers announce + /// "checked"/"not checked" accurately. + pub fn aria_checked(mut self, checked: bool) -> Self { + self.aria_checked = Some(checked); + self + } + /// Reports this item as the accessibility focus, overriding the element /// that holds real keyboard focus. Requires [`Self::aria_role`] to be set. /// @@ -313,15 +335,20 @@ impl RenderOnce for ListItem { |this| this.aria_active_descendant(), ) .when_some(self.aria_label, |this, label| this.aria_label(label)) + .when_some(self.aria_keyshortcuts, |this, keyshortcuts| { + this.aria_keyshortcuts(keyshortcuts) + }) .when(self.aria_role.is_some(), |this| { - this.aria_selected(self.selected) - .when_some(self.toggle, |this, toggled| { + this.aria_selected(self.selected).when_some( + self.aria_checked.or(self.toggle), + |this, toggled| { this.aria_toggled(if toggled { gpui::Toggled::True } else { gpui::Toggled::False }) - }) + }, + ) }) .group("list_item") .w_full() diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index edf90aca43..9991b92ce3 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -348,6 +348,7 @@ pub struct Switch { color: SwitchColor, tab_index: Option, aria_label: Option, + aria_description: Option, } impl Switch { @@ -366,6 +367,7 @@ impl Switch { color: SwitchColor::default(), tab_index: None, aria_label: None, + aria_description: None, } } @@ -431,10 +433,17 @@ impl Switch { self.aria_label = Some(label.into()); self } + + /// Sets the supplementary description announced by assistive technology + /// after the switch's name, role, and state. + pub fn aria_description(mut self, description: impl Into) -> Self { + self.aria_description = Some(description.into()); + self + } } impl RenderOnce for Switch { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let is_on = self.toggle_state == ToggleState::Selected; let adjust_ratio = if is_light(cx) { 1.5 } else { 1.0 }; @@ -457,11 +466,22 @@ impl RenderOnce for Switch { let group_id = format!("switch_group_{:?}", self.id); let label = self.label; let aria_label = self.aria_label.or_else(|| label.clone()); + let aria_description = self.aria_description; + let aria_keyshortcuts = self + .key_binding + .as_ref() + .and_then(|key_binding| key_binding.keyboard_shortcut_text(window, cx)); let switch = div() .id((self.id.clone(), "switch")) .role(Role::Switch) .when_some(aria_label, |this, label| this.aria_label(label)) + .when_some(aria_keyshortcuts, |this, keyshortcuts| { + this.aria_keyshortcuts(keyshortcuts) + }) + .when_some(aria_description, |this, description| { + this.aria_description(description) + }) .aria_toggled(match self.toggle_state { ToggleState::Selected => Toggled::True, ToggleState::Indeterminate => Toggled::Mixed, diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 8bac0fcefd..139da5f7a9 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -1357,6 +1357,8 @@ impl Render for PanelButtons { let button = IconButton::new((name, is_active_button as u64), icon) .icon_size(IconSize::Small) .toggle_state(is_active_button) + .tab_index(0isize) + .aria_label(icon_tooltip) .on_click({ let action = action.boxed_clone(); move |_, window, cx| { diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index ae34936047..0cf9e97b13 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -3,8 +3,8 @@ use crate::{ sidebar_side_context_menu, }; use gpui::{ - Anchor, AnyView, App, Context, Decorations, Entity, IntoElement, ParentElement, Render, - SharedString, Styled, Subscription, WeakEntity, Window, + Anchor, AnyView, App, Context, Decorations, Entity, FocusHandle, Focusable, IntoElement, + ParentElement, Render, Role, SharedString, Styled, Subscription, WeakEntity, Window, }; use settings::{SettingsContent, update_settings_file}; use std::{any::TypeId, sync::Arc}; @@ -102,14 +102,51 @@ pub struct StatusBar { right_items: Vec>, active_pane: Entity, multi_workspace: Option>, + focus_handle: FocusHandle, _observe_active_pane: Subscription, } +impl Focusable for StatusBar { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + impl Render for StatusBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let sidebar = SidebarStatus::query(&self.multi_workspace, cx); h_flex() + .id("status-bar") + .track_focus(&self.focus_handle) + .key_context("StatusBar") + // Expose the status bar as an ARIA toolbar so assistive technology + // announces it as a toolbar and region navigation can reach its + // controls. The controls inside form a tab group: region navigation + // lands on the first control (per the ARIA toolbar pattern), Tab + // steps through them, and arrow keys move between them once focus is + // inside. + .role(Role::Toolbar) + .aria_label("Status bar") + .tab_group() + .on_key_down( + cx.listener(|status_bar, event: &gpui::KeyDownEvent, window, cx| { + if event.keystroke.modifiers.modified() { + return; + } + match event.keystroke.key.as_str() { + "right" => { + status_bar.move_item_focus(true, window, cx); + cx.stop_propagation(); + } + "left" => { + status_bar.move_item_focus(false, window, cx); + cx.stop_propagation(); + } + _ => {} + } + }), + ) .w_full() .justify_between() .gap(DynamicSpacing::Base08.rems(cx)) @@ -222,6 +259,8 @@ impl StatusBar { }, ) .icon_size(IconSize::Small) + .tab_index(0isize) + .aria_label("Open threads sidebar") .when(has_notifications, |this| { this.indicator(Indicator::dot().color(Color::Accent)) .indicator_border_color(Some(indicator_border)) @@ -296,6 +335,7 @@ impl StatusBar { right_items: Default::default(), active_pane: active_pane.clone(), multi_workspace, + focus_handle: cx.focus_handle(), _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| { this.update_active_pane_item(window, cx) }), @@ -412,6 +452,26 @@ impl StatusBar { item.set_active_pane_item(active_pane_item.as_deref(), window, cx); } } + + /// Moves focus between the interactive controls within the status bar in + /// response to arrow keys. Navigation is clamped to the status bar so + /// arrows move between items and stop at the ends (ARIA toolbar semantics); + /// Tab is still used to leave the toolbar. + fn move_item_focus(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { + let previous = window.focused(cx); + if forward { + window.focus_next(cx); + } else { + window.focus_prev(cx); + } + let landed_in_status_bar = window + .focused(cx) + .is_some_and(|handle| self.focus_handle.contains(&handle, window)); + if !landed_in_status_bar && let Some(previous) = previous { + window.focus(&previous, cx); + } + cx.notify(); + } } impl StatusItemViewHandle for Entity { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0fc345b1b7..23c9a5cf55 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -155,8 +155,9 @@ use util::{ }; use uuid::Uuid; pub use workspace_settings::{ - AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, FocusFollowsMouse, + AccessibleMode, AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, FocusFollowsMouse, RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings, WorkspaceSettings, + observe_accessible_mode, }; use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode}; @@ -253,6 +254,13 @@ actions!( ActivatePreviousPane, /// Activates the last pane in the workspace. ActivateLastPane, + /// Moves focus to the next major region of the window (editor, open + /// panels, status bar), cycling and wrapping around. Intended as a + /// discoverable, screen-reader-friendly way to navigate the window. + FocusNextPart, + /// Moves focus to the previous major region of the window. See + /// [`FocusNextPart`]. + FocusPreviousPart, /// Switches to the next window. ActivateNextWindow, /// Switches to the previous window. @@ -1375,6 +1383,8 @@ pub struct Workspace { pub(crate) modal_layer: Entity, toast_layer: Entity, titlebar_item: Option, + titlebar_focus_handle: FocusHandle, + region_focus_handles: RegionFocusHandles, notifications: Notifications, suppressed_notifications: HashSet, project: Entity, @@ -1822,6 +1832,8 @@ impl Workspace { modal_layer, toast_layer, titlebar_item: None, + titlebar_focus_handle: cx.focus_handle(), + region_focus_handles: RegionFocusHandles::new(cx), notifications: Notifications::default(), suppressed_notifications: HashSet::default(), left_dock, @@ -7406,6 +7418,16 @@ impl Workspace { ThreadStatus::Stopped => context.add("debugger_stopped"), ThreadStatus::Exited | ThreadStatus::Ended => {} } + // A coarse "there is a live debug session" flag (running, stepping, + // or stopped at a breakpoint) used to gate debugger controls like + // step/pause/stop. Distinct from `debugger_running`, which is only + // true while the program is actually executing. + if matches!( + status, + ThreadStatus::Running | ThreadStatus::Stepping | ThreadStatus::Stopped + ) { + context.add("debugger_session"); + } } if self.left_dock.read(cx).is_open() { @@ -7487,6 +7509,12 @@ impl Workspace { .on_action(cx.listener(|workspace, _: &ActivateLastPane, window, cx| { workspace.activate_last_pane(window, cx) })) + .on_action(cx.listener(|workspace, _: &FocusNextPart, window, cx| { + workspace.move_part_focus(true, window, cx); + })) + .on_action(cx.listener(|workspace, _: &FocusPreviousPart, window, cx| { + workspace.move_part_focus(false, window, cx); + })) .on_action( cx.listener(|workspace, _: &ActivateNextWindow, _window, cx| { workspace.activate_next_window(cx) @@ -7982,7 +8010,7 @@ impl Workspace { dock: &Entity, window: &mut Window, cx: &mut App, - ) -> Option
{ + ) -> Option> { if self.zoomed_position == Some(position) { return None; } @@ -7993,7 +8021,30 @@ impl Workspace { leader_border_for_pane(follower_states, &pane, window, cx) }); + // Expose each open dock as a landmark region so assistive technology + // can navigate to it, and so region navigation announces it. While a + // screen reader is active the wrapper is also the focus target for + // region navigation (it carries the landmark role and label), so + // focusing it announces the region instead of falling back to the whole + // window. We only make it focusable in that case so it never adds a + // hitbox or intercepts mouse focus for other users. + let (dock_element_id, dock_label) = match position { + DockPosition::Left => ("left-dock", "Left dock"), + DockPosition::Right => ("right-dock", "Right dock"), + DockPosition::Bottom => ("bottom-dock", "Bottom dock"), + }; + let dock_is_open = dock.read(cx).is_open(); + let a11y_active = window.is_a11y_active(); + let mut container = div() + .id(dock_element_id) + .when(dock_is_open, |this| { + this.role(gpui::Role::Complementary) + .aria_label(dock_label) + .when(a11y_active, |this| { + this.track_focus(self.region_focus_handles.dock(position)) + }) + }) .flex() .overflow_hidden() .flex_none() @@ -8048,6 +8099,168 @@ impl Workspace { Some(container) } + /// Returns the currently-visible major window regions ("parts"), in a stable + /// cyclic order: title bar, left dock, editor, right dock, bottom dock, + /// status bar. Closed docks are skipped. Used by + /// [`FocusNextPart`]/[`FocusPreviousPart`] so keyboard and screen-reader + /// users can move between regions without a mouse. + fn focusable_parts(&self, cx: &App) -> Vec { + // The interactive focus target inside an open dock: the active panel, or + // the dock itself as a fallback. This is what region navigation focuses + // when no screen reader is active (preserving the previous behavior). + fn dock_content_handle(dock: &Entity, cx: &App) -> FocusHandle { + let dock = dock.read(cx); + dock.active_panel() + .map(|panel| panel.panel_focus_handle(cx)) + .unwrap_or_else(|| dock.focus_handle(cx)) + } + + let dock_part = |dock: &Entity, wrapper: &FocusHandle| { + dock.read(cx) + .is_open() + .then(|| FocusablePart::landmark(wrapper.clone(), dock_content_handle(dock, cx))) + }; + + let mut parts = Vec::new(); + if self.titlebar_item.is_some() { + // The title bar is an ARIA toolbar, so region navigation lands on + // its first control rather than the toolbar container. + parts.push(FocusablePart::toolbar(self.titlebar_focus_handle.clone())); + } + parts.extend(dock_part( + &self.left_dock, + &self.region_focus_handles.left_dock, + )); + + let center_pane = self + .last_active_center_pane + .as_ref() + .and_then(|pane| pane.upgrade()) + .unwrap_or_else(|| self.center.first_pane()); + parts.push(FocusablePart::landmark( + self.region_focus_handles.editor.clone(), + center_pane.read(cx).focus_handle(cx), + )); + + parts.extend(dock_part( + &self.right_dock, + &self.region_focus_handles.right_dock, + )); + parts.extend(dock_part( + &self.bottom_dock, + &self.region_focus_handles.bottom_dock, + )); + // The status bar is an ARIA toolbar, so region navigation lands on its + // first control rather than the toolbar container. + parts.push(FocusablePart::toolbar( + self.status_bar.read(cx).focus_handle(cx), + )); + parts + } + + /// Moves focus to the next (or previous) visible window region. See + /// [`FocusNextPart`]. + fn move_part_focus(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { + let parts = self.focusable_parts(cx); + if parts.is_empty() { + return; + } + let current = parts + .iter() + .position(|part| part.contains_focused(window, cx)); + let next_index = match current { + Some(index) if forward => (index + 1) % parts.len(), + Some(index) => (index + parts.len() - 1) % parts.len(), + None => 0, + }; + let part = &parts[next_index]; + match &part.behavior { + PartBehavior::Toolbar => { + // The ARIA toolbar pattern requires focus to rest on the first + // control, not the container. Focusing the tab-group container + // and advancing descends into its first control; if the toolbar + // has no focusable control, restore focus to the container so + // navigation doesn't escape into an unrelated region. + let container = part.container.clone(); + window.focus(&container, cx); + window.focus_next(cx); + let landed_inside = window + .focused(cx) + .is_some_and(|handle| container.contains(&handle, window)); + if !landed_inside { + window.focus(&container, cx); + } + } + PartBehavior::Landmark { content } => { + // Only redirect to the (otherwise non-focusable) wrapper when a + // screen reader is active, so it is announced as a landmark. + // Without a screen reader, focus the interactive content so + // sighted keyboard users land somewhere usable, unchanged from + // before this feature existed. + if window.is_a11y_active() { + window.focus(&part.container, cx); + } else { + window.focus(content, cx); + } + } + } + cx.notify(); + } + + /// Moves focus between the interactive controls within the title bar + /// toolbar in response to arrow keys. Navigation is clamped to the title + /// bar so arrows move between items and stop at the ends (ARIA toolbar + /// semantics); Tab is still used to leave the toolbar. + fn move_titlebar_item_focus( + &mut self, + forward: bool, + window: &mut Window, + cx: &mut Context, + ) { + let previous = window.focused(cx); + if forward { + window.focus_next(cx); + } else { + window.focus_prev(cx); + } + let landed_in_titlebar = window + .focused(cx) + .is_some_and(|handle| self.titlebar_focus_handle.contains(&handle, window)); + // If Tab navigation wandered out of the toolbar, restore the previous + // item so the ends of the toolbar act as stops rather than exits. + if !landed_in_titlebar && let Some(previous) = previous { + window.focus(&previous, cx); + } + cx.notify(); + } + + /// Renders the center pane group wrapped in a `Main` landmark so assistive + /// technology recognizes the editor as the main region and can navigate to + /// it. While a screen reader is active the wrapper is also the focus target + /// for region navigation (it carries the landmark role and label), so + /// focusing it announces "Editor" instead of falling back to the whole + /// window. We only make it focusable in that case so it never adds a hitbox + /// or intercepts mouse focus for other users. + fn render_center( + &self, + render_cx: &PaneRenderContext, + window: &mut Window, + cx: &mut App, + ) -> impl IntoElement { + div() + .id("editor-region") + .role(gpui::Role::Main) + .aria_label("Editor") + .when(window.is_a11y_active(), |this| { + this.track_focus(&self.region_focus_handles.editor) + }) + .size_full() + .child( + self.center + .render(self.zoomed.as_ref(), render_cx, window, cx), + ) + } + pub fn for_window(window: &Window, cx: &App) -> Option> { window .root::() @@ -8522,6 +8735,92 @@ enum ActivateInDirectionTarget { Sidebar(FocusHandle), } +/// A focusable major window region used by region navigation +/// ([`FocusNextPart`]/[`FocusPreviousPart`]). +struct FocusablePart { + /// The region's container/ancestor handle. For landmark regions this is the + /// wrapper that carries the landmark role + label (and is only focusable + /// while a screen reader is active); for toolbars it is the toolbar + /// container. + container: FocusHandle, + behavior: PartBehavior, +} + +enum PartBehavior { + /// An ARIA toolbar (title bar, status bar). Region navigation focuses the + /// container and descends to its first control, which is usable for + /// everyone, so this is not gated on assistive technology. + Toolbar, + /// A landmark region (a dock or the editor). The wrapper carries the + /// landmark role + label, so when a screen reader is active we focus it so + /// it is announced. Otherwise we focus the interactive `content` so sighted + /// keyboard users land on something usable, exactly as before - and the + /// wrapper is left non-focusable so it never affects mouse focus. + Landmark { content: FocusHandle }, +} + +impl FocusablePart { + fn toolbar(container: FocusHandle) -> Self { + Self { + container, + behavior: PartBehavior::Toolbar, + } + } + + fn landmark(wrapper: FocusHandle, content: FocusHandle) -> Self { + Self { + container: wrapper, + behavior: PartBehavior::Landmark { content }, + } + } + + /// Whether focus currently lies within this region. Checks both the wrapper + /// (focused/ancestor when a screen reader is active) and, for landmarks, the + /// interactive content (focused when a screen reader is not active, since + /// the wrapper isn't in the focus tree then). + fn contains_focused(&self, window: &Window, cx: &App) -> bool { + if self.container.contains_focused(window, cx) { + return true; + } + match &self.behavior { + PartBehavior::Landmark { content } => content.contains_focused(window, cx), + PartBehavior::Toolbar => false, + } + } +} + +/// Focus handles for the landmark region wrappers (the docks and the editor). +/// Region navigation ([`FocusNextPart`]/[`FocusPreviousPart`]) focuses these so +/// it lands on the wrapper that actually carries the landmark role and label +/// (e.g. `Main`/"Editor", `Complementary`/"Left dock"). Without them, region +/// navigation would focus an inner element that has no accessibility node, and +/// assistive technology would fall back to announcing the whole window. +struct RegionFocusHandles { + left_dock: FocusHandle, + right_dock: FocusHandle, + bottom_dock: FocusHandle, + editor: FocusHandle, +} + +impl RegionFocusHandles { + fn new(cx: &mut App) -> Self { + Self { + left_dock: cx.focus_handle(), + right_dock: cx.focus_handle(), + bottom_dock: cx.focus_handle(), + editor: cx.focus_handle(), + } + } + + fn dock(&self, position: DockPosition) -> &FocusHandle { + match position { + DockPosition::Left => &self.left_dock, + DockPosition::Right => &self.right_dock, + DockPosition::Bottom => &self.bottom_dock, + } + } +} + fn notify_if_database_failed(window: WindowHandle, cx: &mut AsyncApp) { window .update(cx, |multi_workspace, _, cx| { @@ -8682,7 +8981,42 @@ impl Render for Workspace { .items_start() .text_color(colors.text) .overflow_hidden() - .children(self.titlebar_item.clone()) + // Expose the title bar as an ARIA toolbar so region navigation + // (FocusNextPart) can reach the top bar's controls and assistive + // technology announces it as a toolbar. The contained controls form + // a tab group: region navigation lands on the first control (per + // the ARIA toolbar pattern), Tab steps through them, and arrow keys + // move between them once focus is inside. + .when_some(self.titlebar_item.clone(), |this, item| { + this.child( + div() + .id("titlebar-region") + .track_focus(&self.titlebar_focus_handle) + .tab_group() + .role(gpui::Role::Toolbar) + .aria_label("Title bar") + .on_key_down(cx.listener( + |workspace, event: &gpui::KeyDownEvent, window, cx| { + if event.keystroke.modifiers.modified() { + return; + } + match event.keystroke.key.as_str() { + "right" => { + workspace.move_titlebar_item_focus(true, window, cx); + cx.stop_propagation(); + } + "left" => { + workspace.move_titlebar_item_focus(false, window, cx); + cx.stop_propagation(); + } + _ => {} + } + }, + )) + .w_full() + .child(item), + ) + }) .on_modifiers_changed(move |_, _, cx| { for &id in ¬ification_entities { cx.notify(id); @@ -8818,8 +9152,7 @@ impl Render for Workspace { .when_some(paddings.0, |this, p| { this.child(p.border_r_1()) }) - .child(self.center.render( - self.zoomed.as_ref(), + .child(self.render_center( &pane_render_context, window, cx, @@ -8884,8 +9217,7 @@ impl Render for Workspace { ) }, ) - .child(self.center.render( - self.zoomed.as_ref(), + .child(self.render_center( &pane_render_context, window, cx, @@ -8952,8 +9284,7 @@ impl Render for Workspace { ) }, ) - .child(self.center.render( - self.zoomed.as_ref(), + .child(self.render_center( &pane_render_context, window, cx, @@ -9004,8 +9335,7 @@ impl Render for Workspace { .when_some(paddings.0, |this, p| { this.child(p.border_r_1()) }) - .child(self.center.render( - self.zoomed.as_ref(), + .child(self.render_center( &pane_render_context, window, cx, diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index b450a3260c..76c0713d29 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -2,13 +2,14 @@ use std::{num::NonZeroUsize, time::Duration}; use crate::DockPosition; use collections::HashMap; +use gpui::{App, Subscription}; use serde::Deserialize; -use settings::CommandAliasTarget; pub use settings::{ AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity, PaneSplitDirectionHorizontal, PaneSplitDirectionVertical, RegisterSetting, RestoreOnStartupBehavior, Settings, }; +use settings::{CommandAliasTarget, SettingsStore}; #[derive(RegisterSetting)] pub struct WorkspaceSettings { @@ -27,6 +28,7 @@ pub struct WorkspaceSettings { pub drop_target_size: f32, pub use_system_path_prompts: bool, pub use_system_prompts: bool, + pub accessible_mode: bool, pub command_aliases: HashMap, pub max_tabs: Option, pub when_closing_with_no_tabs: settings::CloseWindowWhenNoItems, @@ -108,6 +110,7 @@ impl Settings for WorkspaceSettings { drop_target_size: workspace.drop_target_size.unwrap(), use_system_path_prompts: workspace.use_system_path_prompts.unwrap(), use_system_prompts: workspace.use_system_prompts.unwrap(), + accessible_mode: workspace.accessible_mode.unwrap(), command_aliases: workspace.command_aliases.clone(), max_tabs: workspace.max_tabs, when_closing_with_no_tabs: workspace.when_closing_with_no_tabs.unwrap(), @@ -143,6 +146,39 @@ impl Settings for WorkspaceSettings { } } +/// Provides convenient access to whether "accessible mode" is enabled, mirroring +/// [`theme::ActiveTheme`] for the active theme. Import this trait to call +/// `cx.accessible_mode()`. +pub trait AccessibleMode { + /// Returns whether accessible mode is enabled. + fn accessible_mode(&self) -> bool; +} + +impl AccessibleMode for App { + fn accessible_mode(&self) -> bool { + WorkspaceSettings::get_global(self).accessible_mode + } +} + +/// Observes changes to the accessible-mode setting, invoking `callback` with the +/// new value whenever it changes. Mirrors the common +/// `cx.observe_global::` pattern, but only fires when the value +/// actually changes. The returned [`Subscription`] must be retained for the +/// callback to keep firing. +pub fn observe_accessible_mode( + cx: &mut App, + mut callback: impl FnMut(bool, &mut App) + 'static, +) -> Subscription { + let mut last = cx.accessible_mode(); + cx.observe_global::(move |cx| { + let current = cx.accessible_mode(); + if current != last { + last = current; + callback(current, cx); + } + }) +} + impl Settings for TabBarSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let tab_bar = content.tab_bar.clone().unwrap(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 01f887b6b4..139066d2fa 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -159,9 +159,28 @@ actions!( [ /// Opens a prompt to enter a URL to open. OpenUrlPrompt, + /// Dumps the current accessibility tree (the last update sent to the + /// platform adapter) to a new buffer as JSON, for debugging what is + /// exposed to assistive technology. + DumpAccessibilityTree, + /// Copies the current accessibility tree to the clipboard as JSON, + /// without opening a buffer. See [`DumpAccessibilityTree`]. + CopyAccessibilityTree, ] ); +/// Serializes the window's most recent accessibility tree to JSON for the +/// `dev: dump/copy accessibility tree` actions, falling back to a friendly +/// placeholder when no tree has been built yet. +fn accessibility_tree_dump(window: &Window) -> String { + window.debug_a11y_tree_json().unwrap_or_else(|| { + "No accessibility tree has been built yet. The tree is only \ + produced once assistive technology (e.g. a screen reader) is \ + active for this window." + .to_string() + }) +} + #[cfg(debug_assertions)] actions!( dev, @@ -911,6 +930,57 @@ fn register_actions( workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); }, ) + .register_action( + |workspace: &mut Workspace, + _: &DumpAccessibilityTree, + window: &mut Window, + cx: &mut Context| { + let json = accessibility_tree_dump(window); + let language = workspace.app_state().languages.language_for_name("JSON"); + cx.spawn_in(window, async move |workspace, cx| { + let language = language.await.log_err(); + workspace + .update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer(&json, language, true, cx) + }); + let title = "Accessibility Tree".to_string(); + let buffer = cx.new(|cx| { + MultiBuffer::singleton(buffer, cx).with_title(title.clone()) + }); + let editor = cx.new(|cx| { + let mut editor = Editor::for_multibuffer( + buffer, + Some(project), + window, + cx, + ); + editor.set_breadcrumb_header(title); + editor + }); + workspace.add_item_to_active_pane( + Box::new(editor), + None, + true, + window, + cx, + ); + }) + .log_err(); + }) + .detach(); + }, + ) + .register_action( + |_workspace: &mut Workspace, + _: &CopyAccessibilityTree, + window: &mut Window, + cx: &mut Context| { + let json = accessibility_tree_dump(window); + cx.write_to_clipboard(ClipboardItem::new_string(json)); + }, + ) .register_action(|_, _: &Minimize, window, _| { window.minimize_window(); })