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:
1 parent
b340949cb6
commit
ab43548338
48 files changed
+1790
-165
No files matched your search
Generated
+12
@@ -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
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 ¬ification_entities {
|
||||
cx.notify(id);
|
||||
@@ -8818,8 +9152,7 @@ impl Render for Workspace {
|
||||
.when_some(paddings.0, |this, p| {
|
||||
this.child(p.border_r_1())
|
||||
})
|
||||
.child(self.center.render(
|
||||
self.zoomed.as_ref(),
|
||||
.child(self.render_center(
|
||||
&pane_render_context,
|
||||
window,
|
||||
cx,
|
||||
@@ -8884,8 +9217,7 @@ impl Render for Workspace {
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(self.center.render(
|
||||
self.zoomed.as_ref(),
|
||||
.child(self.render_center(
|
||||
&pane_render_context,
|
||||
window,
|
||||
cx,
|
||||
@@ -8952,8 +9284,7 @@ impl Render for Workspace {
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(self.center.render(
|
||||
self.zoomed.as_ref(),
|
||||
.child(self.render_center(
|
||||
&pane_render_context,
|
||||
window,
|
||||
cx,
|
||||
@@ -9004,8 +9335,7 @@ impl Render for Workspace {
|
||||
.when_some(paddings.0, |this, p| {
|
||||
this.child(p.border_r_1())
|
||||
})
|
||||
.child(self.center.render(
|
||||
self.zoomed.as_ref(),
|
||||
.child(self.render_center(
|
||||
&pane_render_context,
|
||||
window,
|
||||
cx,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user