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 ...
This commit is contained in:
Cameron Mcloughlin authored and GitHub committed 2026-07-13 16:37:28 +00:00
1 parent b340949cb6
commit ab43548338
48 files changed
+1790 -165

No files matched your search

Generated
+12
View File
@@ -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"
+1 -1
View File
@@ -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"
+22 -6
View File
@@ -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": {
+23 -6
View File
@@ -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": {
+22 -5
View File
@@ -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,
+3
View File
@@ -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,
@@ -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(
+23
View File
@@ -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)
})
@@ -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))
@@ -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;
+2
View File
@@ -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()
+5
View File
@@ -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| {
+32
View File
@@ -378,6 +378,24 @@ impl<E: Element> Drawable<E> {
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<E: Element> Drawable<E> {
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);
}
+176 -64
View File
@@ -1225,7 +1225,26 @@ pub trait StatefulInteractiveElement: InteractiveElement {
/// Set the accessible label for this element.
fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.interactivity().aria_label = Some(label.into());
self.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<SharedString>) -> 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<SharedString>) -> 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<SharedString>) -> 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<SharedString>) -> 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<SharedString>,
pub(crate) description: Option<SharedString>,
pub(crate) keyshortcuts: Option<SharedString>,
pub(crate) selected: Option<bool>,
pub(crate) expanded: Option<bool>,
pub(crate) toggled: Option<accesskit::Toggled>,
pub(crate) numeric_value: Option<f64>,
pub(crate) min_numeric_value: Option<f64>,
pub(crate) max_numeric_value: Option<f64>,
pub(crate) numeric_value_step: Option<f64>,
pub(crate) value: Option<SharedString>,
pub(crate) placeholder: Option<SharedString>,
pub(crate) orientation: Option<accesskit::Orientation>,
pub(crate) level: Option<usize>,
pub(crate) position_in_set: Option<usize>,
pub(crate) size_of_set: Option<usize>,
pub(crate) row_index: Option<usize>,
pub(crate) column_index: Option<usize>,
pub(crate) row_count: Option<usize>,
pub(crate) column_count: Option<usize>,
}
/// 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<Box<dyn FnOnce(&mut crate::A11ySubtreeBuilder)>>,
pub(crate) report_active_descendant_focus: bool,
pub(crate) override_role: Option<accesskit::Role>,
pub(crate) aria_label: Option<SharedString>,
pub(crate) aria_selected: Option<bool>,
pub(crate) aria_expanded: Option<bool>,
pub(crate) aria_toggled: Option<accesskit::Toggled>,
pub(crate) aria_numeric_value: Option<f64>,
pub(crate) aria_min_numeric_value: Option<f64>,
pub(crate) aria_max_numeric_value: Option<f64>,
pub(crate) aria_numeric_value_step: Option<f64>,
pub(crate) aria_value: Option<SharedString>,
pub(crate) aria_placeholder: Option<SharedString>,
pub(crate) aria_orientation: Option<accesskit::Orientation>,
pub(crate) aria_level: Option<usize>,
pub(crate) aria_position_in_set: Option<usize>,
pub(crate) aria_size_of_set: Option<usize>,
pub(crate) aria_row_index: Option<usize>,
pub(crate) aria_column_index: Option<usize>,
pub(crate) aria_row_count: Option<usize>,
pub(crate) aria_column_count: Option<usize>,
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<Self>) -> 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));
}
}
+4
View File
@@ -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<FocusHandle> {
let handle = self.insertion_history[order.node_insertion_index].focus_handle();
debug_assert!(
+7
View File
@@ -157,6 +157,13 @@ mod any_view {
cx: &mut App,
) -> AnyElement {
let view = view.clone().downcast::<V>().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::<V>());
view.update(cx, |view, cx| view.render(window, cx).into_any_element())
}
}
+13 -1
View File
@@ -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<String> {
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`.
+99 -5
View File
@@ -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<SharedString>,
/// 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<FocusId>,
/// 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<EntityId, &'static str>,
}
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<String> {
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<NodeId>,
#[cfg(debug_assertions)]
node_info: FxHashMap<NodeId, debug::NodeDebugInfo>,
}
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);
}
}
+330
View File
@@ -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<Pixels>,
pub scale_factor: f32,
pub tab_stop_count: usize,
}
struct CapturedFrame {
rendered_at: String,
frame_number: u64,
window_title: Option<SharedString>,
node_count: usize,
tab_stop_count: usize,
viewport_size: Size<Pixels>,
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<String>,
/// 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<String>,
pub source_location: Option<&'static core::panic::Location<'static>>,
}
#[derive(Default)]
pub(crate) struct A11yDebug {
last_tree_update: Option<TreeUpdate>,
last_gpui_focus: Option<NodeId>,
last_active_descendant: Option<NodeId>,
/// 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<CapturedFrame>,
#[cfg(debug_assertions)]
last_node_info: FxHashMap<NodeId, NodeDebugInfo>,
}
impl A11yDebug {
pub(crate) fn capture(
&mut self,
update: &TreeUpdate,
gpui_focus: Option<NodeId>,
active_descendant: Option<NodeId>,
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<NodeId, NodeDebugInfo>) {
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<String> {
let update = self.last_tree_update.as_ref()?;
let mut ephemeral: FxHashMap<NodeId, String> = 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<String>,
synthetic: Option<bool>,
}
fn node_to_json(
id: NodeId,
node: &accesskit::Node,
ephemeral: &FxHashMap<NodeId, String>,
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<String> = 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<String> = 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()
}
@@ -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| {
+2
View File
@@ -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| {
@@ -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);
@@ -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(
+1
View File
@@ -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),
+5
View File
@@ -77,6 +77,11 @@ pub struct WorkspaceSettingsContent {
///
/// Default: auto ("on" on macOS, "off" otherwise)
pub when_closing_with_no_tabs: Option<CloseWindowWhenNoItems>,
/// Whether to optimize Zed's interface for assistive technology such as
/// screen readers.
///
/// Default: false
pub accessible_mode: Option<bool>,
/// 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.
///
@@ -20,6 +20,7 @@ where
tab_index: Option<isize>,
disabled: bool,
aria_label: Option<SharedString>,
aria_description: Option<SharedString>,
on_change: Rc<dyn Fn(T, &mut ui::Window, &mut App) + 'static>,
}
@@ -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<SharedString>) -> Self {
self.aria_description = Some(description.into());
self
}
}
impl<T> RenderOnce for EnumVariantDropdown<T>
@@ -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)
@@ -25,6 +25,7 @@ pub struct SettingsInputField {
action_slot: Option<AnyElement>,
color: Option<Color>,
aria_label: Option<SharedString>,
aria_description: Option<SharedString>,
}
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<SharedString>) -> 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)
@@ -275,6 +275,7 @@ pub struct NumberField<T: NumberFieldType = usize> {
on_change: Rc<dyn Fn(&T, &mut Window, &mut App) + 'static>,
tab_index: Option<isize>,
aria_label: Option<SharedString>,
aria_description: Option<SharedString>,
}
impl<T: NumberFieldType> NumberField<T> {
@@ -316,6 +317,7 @@ impl<T: NumberFieldType> NumberField<T> {
on_change: Rc::new(|_, _, _| {}),
tab_index: None,
aria_label: None,
aria_description: None,
}
}
@@ -350,6 +352,13 @@ impl<T: NumberFieldType> NumberField<T> {
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<SharedString>) -> 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<T: NumberFieldType> RenderOnce for NumberField<T> {
.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<T: NumberFieldType> RenderOnce for NumberField<T> {
.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)
@@ -160,6 +160,8 @@ pub fn render_ollama_model_picker(
field: SettingField<settings::OllamaModelName>,
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();
+14
View File
@@ -139,6 +139,20 @@ fn general_page(cx: &App) -> SettingsPage {
fn general_settings_section(_cx: &App) -> Vec<SettingsPageItem> {
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.",
@@ -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<F>(
current_device_id: Option<DeviceId>,
is_input: bool,
on_select: F,
aria_label: Option<SharedString>,
aria_description: Option<SharedString>,
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<T: AsRef<Option<String>> + From<Option<
field: SettingField<T>,
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<T: AsRef<Option<String>> + From<Option<
)
.log_err();
},
Some(SharedString::new_static(title)),
(!description.is_empty()).then(|| SharedString::new_static(description)),
window,
cx,
)
@@ -136,18 +146,22 @@ pub fn render_input_audio_device_dropdown(
field: SettingField<AudioInputDeviceName>,
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<AudioOutputDeviceName>,
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)
}
@@ -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,
);
+50 -29
View File
@@ -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<T>,
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<SettingsWindow>| {
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<Div> {
// 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<SharedString> {
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<T: From<String> + Into<String> + AsRef<str> + Clone>(
field: SettingField<T>,
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<T: From<String> + Into<String> + AsRef<str> + 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<B: Into<bool> + From<bool> + Copy>(
field: SettingField<B>,
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<B: Into<bool> + From<bool> + 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<T: NumberFieldType + Send + Sync>(
field: SettingField<T>,
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<T: NumberFieldType + Send + Sync>(
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<T>(
field: SettingField<T>,
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<settings::FontFamilyName>,
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<settings::ThemeName>,
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<settings::IconThemeName>,
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(),
))
+20 -3
View File
@@ -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<String>,
_settings_subscription: Subscription,
}
impl ApplicationMenu {
pub fn new(_: &mut Window, cx: &mut Context<Self>) -> 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::<SettingsStore>(|_, 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()
}
}
+27 -11
View File
@@ -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))
};
@@ -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| {
+37 -1
View File
@@ -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<SharedString>) -> 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<SharedString>) -> 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 {
+40 -1
View File
@@ -493,7 +493,10 @@ pub struct ButtonLike {
size: ButtonSize,
rounding: Option<ButtonLikeRounding>,
pub(super) aria_label: Option<SharedString>,
aria_role: Option<Role>,
aria_description: Option<SharedString>,
pub(super) aria_value: Option<SharedString>,
pub(super) aria_keyshortcuts: Option<SharedString>,
pub(super) aria_role: Option<Role>,
aria_expanded: Option<bool>,
toggled: Option<bool>,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> 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<SharedString>) -> 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<SharedString>) -> 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<SharedString>) -> 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)
})
+65 -1
View File
@@ -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<Self>) {
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<Self>) -> Option<usize> {
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<Self>,
) -> 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| {
+47
View File
@@ -35,6 +35,8 @@ pub struct DropdownMenu {
tab_index: Option<isize>,
chevron: bool,
aria_label: Option<SharedString>,
aria_description: Option<SharedString>,
aria_value: Option<SharedString>,
}
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<SharedString>) -> 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<SharedString>) -> 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) {
+44
View File
@@ -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<Vec<KeybindingKeystroke>> {
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<SharedString> {
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(
+30 -3
View File
@@ -55,6 +55,8 @@ pub struct ListItem {
height: Option<DefiniteLength>,
aria_role: Option<Role>,
aria_label: Option<SharedString>,
aria_keyshortcuts: Option<SharedString>,
aria_checked: Option<bool>,
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<SharedString>) -> 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()
+21 -1
View File
@@ -348,6 +348,7 @@ pub struct Switch {
color: SwitchColor,
tab_index: Option<isize>,
aria_label: Option<SharedString>,
aria_description: Option<SharedString>,
}
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<SharedString>) -> 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,
+2
View File
@@ -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| {
+62 -2
View File
@@ -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<Box<dyn StatusItemViewHandle>>,
active_pane: Entity<Pane>,
multi_workspace: Option<WeakEntity<MultiWorkspace>>,
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<Self>) -> 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<Self>) {
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<T: StatusItemView> StatusItemViewHandle for Entity<T> {
+341 -11
View File
@@ -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<ModalLayer>,
toast_layer: Entity<ToastLayer>,
titlebar_item: Option<AnyView>,
titlebar_focus_handle: FocusHandle,
region_focus_handles: RegionFocusHandles,
notifications: Notifications,
suppressed_notifications: HashSet<NotificationId>,
project: Entity<Project>,
@@ -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<Dock>,
window: &mut Window,
cx: &mut App,
) -> Option<Div> {
) -> Option<Stateful<Div>> {
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<FocusablePart> {
// 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<Dock>, 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<Dock>, 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<Self>) {
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<Self>,
) {
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<Entity<Workspace>> {
window
.root::<MultiWorkspace>()
@@ -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<MultiWorkspace>, 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 &notification_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,
+37 -1
View File
@@ -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<String, CommandAliasTarget>,
pub max_tabs: Option<NonZeroUsize>,
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::<SettingsStore>` 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::<SettingsStore>(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();
+70
View File
@@ -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<Workspace>| {
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<Workspace>| {
let json = accessibility_tree_dump(window);
cx.write_to_clipboard(ClipboardItem::new_string(json));
},
)
.register_action(|_, _: &Minimize, window, _| {
window.minimize_window();
})