a11y: Settings UI (#59429)

Adds:
- aria attributes to most UI elements in settings UI
- a new a11y API to GPUI
- a fix for a GPUI keyboard focus bug

## Accesible settings UI

Settings UI should now be fully accessible to users of assistive
technology.

**However**, there are some caveats:
- accessibility features require zed to be launched with the
`ZED_EXPERIMENTAL_A11Y=1` env var to be set
- I have not exhaustively checked every control
- There are some quite surprising keyboard focus behaviours which
predate this code
- The main Zed UI is still largely inaccessible, though a handful of
shared components will now report themselves, but the experience is
suboptimal.

For anyone wishing to try out the settings UI:
- make sure `ZED_EXPERIMENTAL_A11Y=1` is set
- open zed
- press `ctrl-,` (or `cmd+,` on a mac) to open settings UI in a separate
window

## `aria_active_descendant`

This is equivalent to the
[`aria-activedescendant`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-activedescendant)
API on the web.

It allows a container to maintain focus, while indicating that one of
its children should be active. Zed uses this for menus and combo boxes,
for example.

GPUI will report a div with `.aria_active_descendant()` as long as:
- an ancestor is focused
- only one descendant of the focused ancestor has
`.aria_active_descendant()`

Two descendants with this property where the focused node is a common
ancestor is an error.

## GPUI keyboard fix

GPUI will map a space or enter keypress to a div's `on_click` handler,
if it exists. However, the keyboard-driven path was missing some checks
that the mouseclick path has.

With this PR, for an enter/space keypress to be considered a click,
between the "key down" and "key up" events:
- there must be no other key events
- focus must not move to a different node

This fixes an existing bug with combo boxes in zed where:
- enter maps to `menu::Confirm` **on key down*
- the menu item is selected
- focus moves to the combo box
- enter then triggers the combo box's `on_click` **on key up**,
re-opening the menu

---

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
Cameron Mcloughlinandzed-zippy[bot] authored and GitHub committed 2026-06-17 17:42:38 +00:00
1 parent 381dc0bbe3
commit f6512349ff
26 files changed
+1911 -244

No files matched your search

Generated
+26 -16
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "accesskit"
version = "0.24.0"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
dependencies = [
"uuid",
]
@@ -18,7 +18,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "842fd8203e6dfcf531d24f5bac792088edfba7d6b35844fead191603fb32a260"
dependencies = [
"accesskit",
"accesskit_consumer",
"accesskit_consumer 0.35.0",
"atspi-common",
"phf 0.13.1",
"serde",
@@ -35,6 +35,16 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_macos"
version = "0.26.0"
@@ -42,7 +52,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "534bc3fdc89a64a1db3c46b33c198fde2b7c3c7d094e5809c8c8bf2970c18243"
dependencies = [
"accesskit",
"accesskit_consumer",
"accesskit_consumer 0.35.0",
"hashbrown 0.16.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
@@ -69,12 +79,12 @@ dependencies = [
[[package]]
name = "accesskit_windows"
version = "0.32.1"
version = "0.33.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2"
dependencies = [
"accesskit",
"accesskit_consumer",
"accesskit_consumer 0.37.0",
"hashbrown 0.16.1",
"static_assertions",
"windows 0.62.2",
@@ -5270,7 +5280,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6067,7 +6077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7277,7 +7287,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11148,7 +11158,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -11578,7 +11588,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -15669,7 +15679,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -17111,7 +17121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -18228,7 +18238,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -21230,7 +21240,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+1 -1
View File
@@ -502,7 +502,7 @@ ztracing_macro = { path = "crates/ztracing_macro" }
accesskit = "0.24.0"
accesskit_macos = "0.26.0"
accesskit_unix = "0.21.0"
accesskit_windows = "0.32.1"
accesskit_windows = "0.33.1"
agent-client-protocol = { version = "=0.14.0", features = ["unstable"] }
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
+53 -1
View File
@@ -9,7 +9,8 @@
//!
//! This guide is focused on **programmatic accessibility**. This allows
//! assistive technology, such as screen readers or Braille displays, to inspect
//! and interact with your app.
//! and interact with your app. Docs for contributors working on accessibility
//! support can be found in the `a11y` module's doc comment.
//!
//! GPUI integrates with [AccessKit] to provide programmatic accessibility
//! features (referred to as simply "accessibility" for the rest of this guide).
@@ -219,6 +220,57 @@
//! [`.on_click()`][StatefulInteractiveElement::on_click] adds an
//! [`AccessibleAction::Click`] handler that calls the click handler.
//!
//! ## Synthetic children
//!
//! Sometimes, a custom [`Element`] may want to appear as if it is really made
//! of multiple nodes. For example, a totally hypothetical custom text editor
//! element may want to have [`Role::TextInput`], while presenting children
//! consisting of [`Role::TextRun`]s.
//!
//! This is possible using [`Element::a11y_synthetic_children`]. For example:
//! ```rust,ignore
//! # use gpui::*;
//! impl Element for MyCustomTextField {
//!
//! // ...
//!
//! fn a11y_role(&self) -> Option<Role> {
//! Some(Role::TextInput)
//! }
//!
//! fn a11y_synthetic_children(
//! &mut self,
//! _prepaint: &mut Self::PrepaintState,
//! builder: &mut A11ySubtreeBuilder,
//! ) {
//! // Create the synthetic child node
//! let mut run = accesskit::Node::new(Role::TextRun);
//! run.set_value(self.text.clone());
//! run.set_character_lengths(
//! self.text.chars().map(|c| c.len_utf8() as u8).collect::<Vec<_>>(),
//! );
//!
//! // Insert it as a child of `MyCustomTextField`
//! let run_id = builder.synthetic_node_id(0);
//! builder.push_child(run_id, run);
//!
//! // You can also mutate the parent (i.e. the `MyCustomTextField`)
//! let caret = accesskit::TextPosition {
//! node: run_id,
//! character_index: self.cursor,
//! };
//! builder.parent_node().set_text_selection(accesskit::TextSelection {
//! anchor: caret,
//! focus: caret,
//! });
//! }
//! }
//! ```
//!
//! Notably, synthetic children are added *after* an element is
//! [prepainted][Element::prepaint], so prepaint state can be used (for example,
//! to determine what is visible on screen).
//!
//! ## Further reading
//!
//! Designing high-quality accessible interfaces can be challenging, in the same
+27 -3
View File
@@ -32,8 +32,8 @@
//! your own custom layout algorithm or rendering a code editor.
use crate::{
App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId, FocusHandle,
InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window,
A11ySubtreeBuilder, App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId,
FocusHandle, InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window,
util::FluentBuilder, window::with_element_arena,
};
use derive_more::{Deref, DerefMut};
@@ -119,6 +119,22 @@ pub trait Element: 'static + IntoElement {
/// See the [accessibility guide](crate::_accessibility) for an overview.
fn write_a11y_info(&self, _node: &mut accesskit::Node) {}
/// Add synthetic child nodes to an [`Element`] that has an
/// [`.id()`][Element::id] and a [`.role()`][Element::a11y_role].
///
/// Some elements may want to inject accessibility nodes that do not
/// correspond to any GPUI element. For example, a custom text field element
/// may want to inject synthetic child nodes for the text content.
///
/// See [Synthetic children](crate::_accessibility#synthetic-children) in
/// the accessibility guide for more detail.
fn a11y_synthetic_children(
&mut self,
_prepaint: &mut Self::PrepaintState,
_builder: &mut A11ySubtreeBuilder,
) {
}
/// Convert this element into a dynamically-typed [`AnyElement`].
fn into_any(self) -> AnyElement {
AnyElement::new(self)
@@ -477,7 +493,7 @@ impl<E: Element> Drawable<E> {
}
let node_id = window.next_frame.dispatch_tree.push_node();
let prepaint = self.element.prepaint(
let mut prepaint = self.element.prepaint(
global_id.as_ref(),
inspector_id.as_ref(),
bounds,
@@ -488,6 +504,14 @@ impl<E: Element> Drawable<E> {
window.next_frame.dispatch_tree.pop_node();
if pushed_a11y_node {
if let Some(global_id) = global_id.as_ref() {
let mut builder = A11ySubtreeBuilder::new(
global_id.accesskit_node_id(),
&mut window.a11y.nodes,
);
self.element
.a11y_synthetic_children(&mut prepaint, &mut builder);
}
window.a11y.nodes.pop();
}
+378 -5
View File
@@ -1201,6 +1201,43 @@ pub trait StatefulInteractiveElement: InteractiveElement {
self
}
/// Report this element as the focused node in the accessibility tree,
/// overriding the element that holds real keyboard focus — but only while
/// one of its ancestors actually holds focus.
///
/// This implements the `aria-activedescendant` pattern for composite
/// widgets that keep keyboard focus on a container (e.g. a menu or
/// listbox) while a child is "selected": set this on the selected child so
/// assistive technology announces and highlights it as focused.
///
/// The element must also have a [`role`][Self::role] (and an id) so it
/// produces an accessibility node. Unlike the web's container-side
/// `aria-activedescendant`, this is set on the descendant; GPUI honors it
/// only when a focused ancestor is present in the tree, so it is safe to
/// set unconditionally on the selected child — if the container isn't
/// focused, the claim is ignored.
fn aria_active_descendant(mut self) -> Self {
self.interactivity().report_active_descendant_focus = true;
self
}
/// Contribute synthetic accessibility nodes — nodes that don't correspond
/// to any element — as children of this element's a11y node. For example,
/// text runs describing an editor's text content.
///
/// The closure is called after this element is prepainted, and only if it
/// contributed a node to the accessibility tree (i.e. it has an id and a
/// [`role`][StatefulInteractiveElement::role]).
///
/// See [`Element::a11y_synthetic_children`] for details.
fn a11y_synthetic_children(
mut self,
f: impl FnOnce(&mut crate::A11ySubtreeBuilder) + 'static,
) -> Self {
self.interactivity().a11y_synthetic_children = Some(Box::new(f));
self
}
/// Set the selected state for this element.
fn aria_selected(mut self, selected: bool) -> Self {
self.interactivity().aria_selected = Some(selected);
@@ -1225,6 +1262,27 @@ pub trait StatefulInteractiveElement: InteractiveElement {
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
}
/// 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
}
/// 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
}
/// 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);
@@ -1622,6 +1680,16 @@ impl Element for Div {
self.interactivity.write_a11y_info(node);
}
fn a11y_synthetic_children(
&mut self,
_prepaint: &mut Self::PrepaintState,
builder: &mut crate::A11ySubtreeBuilder,
) {
if let Some(f) = self.interactivity.a11y_synthetic_children.take() {
f(builder);
}
}
#[stacksafe]
fn request_layout(
&mut self,
@@ -1861,6 +1929,8 @@ pub struct Interactivity {
pub(crate) a11y_action_listeners:
Vec<(accesskit::Action, crate::window::a11y::A11yActionListener)>,
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>,
@@ -1869,6 +1939,9 @@ pub struct Interactivity {
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>,
@@ -2002,13 +2075,21 @@ impl Interactivity {
if window.a11y.is_active() {
if let Some(global_id) = global_id {
let node_id = global_id.accesskit_node_id();
window.a11y.focus_ids.insert(node_id, focus_handle.id);
if focus_handle.is_focused(window) && window.a11y.nodes.has_node(node_id) {
window.a11y.nodes.set_focus(node_id);
window.a11y.set_focusable(node_id, focus_handle.id);
if focus_handle.is_focused(window) {
window.a11y.set_focus(node_id);
}
}
}
}
if self.report_active_descendant_focus && window.a11y.is_active() {
if let Some(global_id) = global_id {
window
.a11y
.set_active_descendant(global_id.accesskit_node_id());
}
}
window.with_optional_element_state::<InteractiveElementState, _>(
global_id,
|element_state, window| {
@@ -2565,6 +2646,11 @@ impl Interactivity {
.get_or_insert_with(Default::default)
.clone();
let pending_keyboard_down = element_state
.pending_keyboard_down
.get_or_insert_with(Default::default)
.clone();
let clicked_state = element_state
.clicked_state
.get_or_insert_with(Default::default)
@@ -2618,6 +2704,29 @@ impl Interactivity {
});
if is_focused {
// Record the focus generation at which an enter/space key
// down event happened on this element. The next key up
// event will be mapped to a click event if both of the
// following are true:
// - no other key events happen in between
// - the focus generation is the same (implying focus did not move)
//
// This design avoids an ABA problem that happens if you
// store the focus handle that registered the keypress.
window.on_key_event({
let pending_keyboard_down = pending_keyboard_down.clone();
move |event: &KeyDownEvent, phase, window, _cx| {
if phase.bubble() && !window.default_prevented() {
let stroke = &event.keystroke;
let is_activation_key = (stroke.key.eq("enter")
|| stroke.key.eq("space"))
&& !stroke.modifiers.modified();
*pending_keyboard_down.borrow_mut() =
is_activation_key.then_some(window.focus_generation);
}
}
});
// Press enter, space to trigger click, when the element is focused.
window.on_key_event({
let click_listeners = click_listeners.clone();
@@ -2636,6 +2745,12 @@ impl Interactivity {
if let Some(button) = keyboard_button
&& !stroke.modifiers.modified()
{
let pending =
std::mem::take(&mut *pending_keyboard_down.borrow_mut());
if pending != Some(window.focus_generation) {
return;
}
let click_event = ClickEvent::Keyboard(KeyboardClickEvent {
button,
bounds: hitbox.bounds,
@@ -2644,6 +2759,11 @@ impl Interactivity {
for listener in &click_listeners {
listener(&click_event, window, cx);
}
} else {
// Releasing any other key mid-press means
// this isn't a clean activation, so cancel
// the pending keydown.
*pending_keyboard_down.borrow_mut() = None;
}
}
}
@@ -3075,6 +3195,15 @@ impl Interactivity {
if let Some(value) = self.aria_max_numeric_value {
node.set_max_numeric_value(value);
}
if let Some(step) = self.aria_numeric_value_step {
node.set_numeric_value_step(step);
}
if let Some(value) = &self.aria_value {
node.set_value(value.to_string());
}
if let Some(placeholder) = &self.aria_placeholder {
node.set_placeholder(placeholder.to_string());
}
if let Some(orientation) = self.aria_orientation {
node.set_orientation(orientation);
}
@@ -3120,6 +3249,14 @@ pub struct InteractiveElementState {
pub(crate) hover_state: Option<Rc<RefCell<ElementHoverState>>>,
pub(crate) hover_listener_state: Option<Rc<RefCell<bool>>>,
pub(crate) pending_mouse_down: Option<Rc<RefCell<Option<MouseDownEvent>>>>,
/// Set to the window's [`focus_generation`](crate::Window::focus_generation)
/// when an Enter/Space keydown is received while this element is focused,
/// recording that we are waiting for the matching keyup to fire a keyboard
/// click. On keyup the click only fires if the stored generation still
/// matches the window's current one, i.e. focus never moved during the
/// press (mirroring the browser clearing a control's pressed state on
/// blur). `None` means no activation key is pending.
pub(crate) pending_keyboard_down: Option<Rc<RefCell<Option<u64>>>>,
pub(crate) scroll_offset: Option<Rc<RefCell<Point<Pixels>>>>,
pub(crate) active_tooltip: Option<Rc<RefCell<Option<ActiveTooltip>>>>,
}
@@ -3552,6 +3689,14 @@ where
self.element.write_a11y_info(node);
}
fn a11y_synthetic_children(
&mut self,
prepaint: &mut Self::PrepaintState,
builder: &mut crate::A11ySubtreeBuilder,
) {
self.element.a11y_synthetic_children(prepaint, builder);
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
@@ -3869,8 +4014,8 @@ impl ScrollHandle {
mod tests {
use super::*;
use crate::{
AppContext as _, Context, InputEvent, MouseMoveEvent, TestAppContext,
util::FluentBuilder as _,
AnyWindowHandle, AppContext as _, Context, InputEvent, Keystroke, MouseMoveEvent,
TestAppContext, util::FluentBuilder as _,
};
use std::rc::Weak;
@@ -4179,4 +4324,232 @@ mod tests {
assert!(active_tooltip.borrow().is_none());
}
#[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);
let mut node = accesskit::Node::new(accesskit::Role::SpinButton);
interactivity.write_a11y_info(&mut node);
assert_eq!(node.label(), Some("Buffer Font Size"));
assert_eq!(node.value(), Some("15"));
assert_eq!(node.placeholder(), Some("Search"));
assert_eq!(node.numeric_value(), Some(15.0));
assert_eq!(node.min_numeric_value(), Some(6.0));
assert_eq!(node.max_numeric_value(), Some(72.0));
assert_eq!(node.numeric_value_step(), Some(1.0));
}
/// Two focusable, clickable elements ("a" and "b") used to exercise the
/// Enter/Space -> synthesized click press/release pairing.
struct KeyboardActivationTest {
focus_a: FocusHandle,
focus_b: FocusHandle,
clicks: Rc<RefCell<Vec<&'static str>>>,
}
impl Render for KeyboardActivationTest {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let clicks_a = self.clicks.clone();
let clicks_b = self.clicks.clone();
div()
.size_full()
.child(
div()
.id("a")
.w(px(50.))
.h(px(50.))
.track_focus(&self.focus_a)
.on_click(move |_, _, _| clicks_a.borrow_mut().push("a")),
)
.child(
div()
.id("b")
.w(px(50.))
.h(px(50.))
.track_focus(&self.focus_b)
.on_click(move |_, _, _| clicks_b.borrow_mut().push("b")),
)
}
}
fn setup_keyboard_activation_test() -> (
TestAppContext,
AnyWindowHandle,
Rc<RefCell<Vec<&'static str>>>,
FocusHandle,
FocusHandle,
) {
let mut cx = TestAppContext::single();
let (focus_a, focus_b) = cx.update(|cx| (cx.focus_handle(), cx.focus_handle()));
let clicks: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
let window = cx.add_window({
let focus_a = focus_a.clone();
let focus_b = focus_b.clone();
let clicks = clicks.clone();
move |_, _| KeyboardActivationTest {
focus_a,
focus_b,
clicks,
}
});
(cx, window.into(), clicks, focus_a, focus_b)
}
/// Move focus to `handle`, flush effects, then paint so the newly focused
/// element registers its key handlers for the next dispatched event.
fn focus_and_draw(cx: &mut TestAppContext, window: AnyWindowHandle, handle: &FocusHandle) {
cx.update_window(window, |_, window, cx| window.focus(handle, cx))
.unwrap();
cx.run_until_parked();
cx.update_window(window, |_, window, cx| {
window.draw(cx).clear();
})
.unwrap();
}
fn key_down(cx: &mut TestAppContext, window: AnyWindowHandle, key: &str) {
let keystroke = Keystroke::parse(key).unwrap();
cx.update_window(window, |_, window, cx| {
window.dispatch_event(
KeyDownEvent {
keystroke,
is_held: false,
prefer_character_input: false,
}
.to_platform_input(),
cx,
);
})
.unwrap();
}
fn key_up(cx: &mut TestAppContext, window: AnyWindowHandle, key: &str) {
let keystroke = Keystroke::parse(key).unwrap();
cx.update_window(window, |_, window, cx| {
window.dispatch_event(KeyUpEvent { keystroke }.to_platform_input(), cx);
})
.unwrap();
}
/// Pressing and releasing Enter on the same focused element fires a click.
#[test]
fn keyboard_activation_fires_click_on_same_element() {
let (mut cx, window, clicks, focus_a, _focus_b) = setup_keyboard_activation_test();
focus_and_draw(&mut cx, window, &focus_a);
key_down(&mut cx, window, "enter");
key_up(&mut cx, window, "enter");
assert_eq!(*clicks.borrow(), vec!["a"]);
}
/// A key-down whose key-up lands on a *different* element (because focus
/// moved in between) must not leak a synthesized click onto the newly
/// focused element. This is the core regression: previously the key-up
/// handler fired unconditionally on whatever was focused at key-up time.
#[test]
fn keyboard_activation_does_not_leak_across_focus_change() {
let (mut cx, window, clicks, focus_a, focus_b) = setup_keyboard_activation_test();
// Enter pressed while "a" is focused...
focus_and_draw(&mut cx, window, &focus_a);
key_down(&mut cx, window, "enter");
// ...focus moves to "b" before the release (as a confirm action would)...
focus_and_draw(&mut cx, window, &focus_b);
key_up(&mut cx, window, "enter");
// ...so neither element is clicked: "a" never saw the up, and "b"
// never saw the down.
assert!(clicks.borrow().is_empty(), "clicks: {:?}", clicks.borrow());
}
/// A keydown whose flag is left pending because focus moved away before
/// the keyup must not fire a click when focus later *returns* to the same
/// element (the menu trigger reopening case). The stamped focus generation
/// no longer matches, so the stale pending state is ignored.
#[test]
fn keyboard_activation_does_not_leak_when_focus_returns() {
let (mut cx, window, clicks, focus_a, focus_b) = setup_keyboard_activation_test();
// Enter pressed on "a"...
focus_and_draw(&mut cx, window, &focus_a);
key_down(&mut cx, window, "enter");
// ...focus leaves "a" before its keyup (so the pending state is never
// consumed), then comes back to "a"...
focus_and_draw(&mut cx, window, &focus_b);
focus_and_draw(&mut cx, window, &focus_a);
key_up(&mut cx, window, "enter");
// ...and the now-stale pending keydown must not fire a click.
assert!(clicks.borrow().is_empty(), "clicks: {:?}", clicks.borrow());
}
/// A non-activation key *released* during the press must cancel the pending
/// activation. For the sequence escape-down, space-down, escape-up,
/// space-up the space forms a clean down/up pair, but the intervening
/// escape-up means this isn't a plain space activation, so no click fires.
#[test]
fn keyboard_activation_cleared_by_intervening_key_release() {
let (mut cx, window, clicks, focus_a, _focus_b) = setup_keyboard_activation_test();
focus_and_draw(&mut cx, window, &focus_a);
key_down(&mut cx, window, "escape");
key_down(&mut cx, window, "space");
key_up(&mut cx, window, "escape");
key_up(&mut cx, window, "space");
assert!(clicks.borrow().is_empty(), "clicks: {:?}", clicks.borrow());
}
/// The flag is a single activation marker, not keyed by which activation
/// key was used, so a Space down paired with an Enter up on the same
/// element still fires a click.
#[test]
fn keyboard_activation_does_not_distinguish_space_and_enter() {
let (mut cx, window, clicks, focus_a, _focus_b) = setup_keyboard_activation_test();
focus_and_draw(&mut cx, window, &focus_a);
key_down(&mut cx, window, "space");
key_up(&mut cx, window, "enter");
assert_eq!(*clicks.borrow(), vec!["a"]);
}
/// A non-activation key pressed between the activation down and up clears
/// the pending flag, suppressing the click.
#[test]
fn keyboard_activation_cleared_by_intervening_keydown() {
let (mut cx, window, clicks, focus_a, _focus_b) = setup_keyboard_activation_test();
focus_and_draw(&mut cx, window, &focus_a);
key_down(&mut cx, window, "enter");
key_down(&mut cx, window, "a");
key_up(&mut cx, window, "enter");
assert!(clicks.borrow().is_empty(), "clicks: {:?}", clicks.borrow());
}
/// A modified Enter (e.g. cmd-enter) is not treated as an activation key,
/// so it neither sets the pending flag nor fires a click on release.
#[test]
fn keyboard_activation_ignores_modified_keys() {
let (mut cx, window, clicks, focus_a, _focus_b) = setup_keyboard_activation_test();
focus_and_draw(&mut cx, window, &focus_a);
key_down(&mut cx, window, "cmd-enter");
key_up(&mut cx, window, "cmd-enter");
assert!(clicks.borrow().is_empty(), "clicks: {:?}", clicks.borrow());
}
}
+39 -2
View File
@@ -62,6 +62,8 @@ use uuid::Uuid;
pub(crate) mod a11y;
mod prompts;
pub use a11y::A11ySubtreeBuilder;
use self::a11y::A11y;
#[cfg(not(target_family = "wasm"))]
use self::a11y::ROOT_NODE_ID;
@@ -1041,6 +1043,9 @@ pub struct Window {
pub(crate) activation_observers: SubscriberSet<(), AnyObserver>,
pub(crate) focus: Option<FocusId>,
focus_enabled: bool,
/// Incremented every time focus moves. Used to invalidate a
/// pending keyboard activation state when focus changes.
pub(crate) focus_generation: u64,
pending_input: Option<PendingInput>,
pending_modifier: ModifierState,
pub(crate) pending_input_observers: SubscriberSet<(), AnyObserver>,
@@ -1303,6 +1308,10 @@ impl Window {
tabbing_identifier,
} = options;
let initial_window_title = titlebar
.as_ref()
.and_then(|titlebar| titlebar.title.clone());
let window_bounds = window_bounds.unwrap_or_else(|| default_bounds(display_id, cx));
let mut platform_window = cx.platform.open_window(
handle,
@@ -1361,8 +1370,12 @@ impl Window {
#[cfg(not(target_family = "wasm"))]
if !accessibility_force_disabled {
let mut initial_root_node = accesskit::Node::new(accesskit::Role::Window);
if let Some(title) = &initial_window_title {
initial_root_node.set_label(title.to_string());
}
let initial_tree = accesskit::TreeUpdate {
nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))],
nodes: vec![(ROOT_NODE_ID, initial_root_node)],
tree: Some(accesskit::Tree::new(ROOT_NODE_ID)),
tree_id: accesskit::TreeId::ROOT,
focus: ROOT_NODE_ID,
@@ -1735,6 +1748,7 @@ impl Window {
activation_observers: SubscriberSet::new(),
focus: None,
focus_enabled: true,
focus_generation: 0,
pending_input: None,
pending_modifier: ModifierState::default(),
pending_input_observers: SubscriberSet::new(),
@@ -1744,7 +1758,11 @@ impl Window {
captured_hitbox: None,
#[cfg(any(feature = "inspector", debug_assertions))]
inspector: None,
a11y: A11y::new(a11y_active_flag, accessibility_force_disabled),
a11y: A11y::new(
a11y_active_flag,
accessibility_force_disabled,
initial_window_title,
),
})
}
@@ -1891,6 +1909,7 @@ impl Window {
}
self.focus = Some(handle.id);
self.focus_generation = self.focus_generation.wrapping_add(1);
self.clear_pending_keystrokes();
// Avoid re-entrant entity updates by deferring observer notifications to the end of the
@@ -1913,6 +1932,9 @@ impl Window {
return;
}
if self.focus.is_some() {
self.focus_generation = self.focus_generation.wrapping_add(1);
}
self.focus = None;
self.refresh();
}
@@ -2335,6 +2357,7 @@ impl Window {
/// Updates the window's title at the platform level.
pub fn set_window_title(&mut self, title: &str) {
self.platform_window.set_title(title);
self.a11y.set_window_title(title.to_string());
}
/// Sets the position of the macOS traffic light buttons.
@@ -5474,6 +5497,20 @@ impl Window {
self.platform_window.play_system_bell()
}
/// Returns whether accessibility features are active for this frame,
/// i.e. whether assistive technology (such as a screen reader) is
/// connected and an accessibility tree is being built.
///
/// Use this to skip computing data during rendering that is only
/// observable through the accessibility tree. When accessibility is
/// activated, a redraw is forced, so gated work is recomputed before the
/// next tree update is sent to the platform.
///
/// See the [accessibility guide](crate::_accessibility) for an overview.
pub fn is_a11y_active(&self) -> bool {
self.a11y.is_active()
}
/// 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`.
+488 -55
View File
@@ -64,8 +64,26 @@
//! a frame have been prepainted, we send the resulting [`TreeUpdate`] object to
//! the adapter and the screen reader can announce the changes.
//!
//! #### Synthetic children
//!
//! Additionally, some nodes can register "synthetic children" using
//! [`Element::a11y_synthetic_children`]. Normally, one accesskit node is pushed
//! for every [`Element`] with a role and id. However, sometimes a single
//! element may want to produce many accesskit nodes. These extra nodes are
//! referred to as "synthetic children" of the element providing a non-default
//! [`Element::a11y_synthetic_children`] implementation.
//!
//! The user is provided a builder-style API using [`A11ySubtreeBuilder`], which
//! allows them to create push nodes that are children of the current node, as
//! well as modify the current node itself.
//!
//! GPUI calls this callback *after* prepainting (and just before popping the
//! corresponding element), since this step may need prepaint information to be
//! available. In the future, we may want to add prepaint information more
//! generally to [`Element::write_a11y_info`], but for now that's not necessary.
//!
//! ### Responding to actions
//!
//!
//! On adapter creation, we provide a callback to the adapter, which can be used
//! to dispatch actions. This callback forwards to [`A11y::action_listeners`], a
//! mapping from [`NodeId`]s to action handlers (basically just `Box<dyn
@@ -74,22 +92,19 @@
//! This is populated in:
//! - [`Window::on_a11y_action`], which is called by:
//! - [`Interactivity::paint`], which is called by:
//! - [`InteractiveElement::on_a11y_action`], which is a public-facing API
//! - [`StatefulInteractiveElement::on_a11y_action`], which is a public-facing API
//!
//! These are cleared at the start of a frame, and re-populated during painting.
//!
//! [`Element`]: crate::Element
//! [`GlobalElementId`]: crate::GlobalElementId
//! [`div()`]: crate::div
//! [`Interactivity::paint`]: crate::Interactivity::paint
//! [`InteractiveElement::on_a11y_action`]: crate::InteractiveElement::on_a11y_action
//! [`NodeId`]: accesskit::NodeId
//! [`Drawable::prepaint`]: crate::Drawable::prepaint
use crate::{App, Bounds, FocusId, Pixels, Window};
use crate::*;
use crate::{App, Bounds, FocusId, Pixels, SharedString, Window};
use accesskit::{Action, NodeId, TreeUpdate};
use collections::{FxHashMap, FxHashSet};
use smallvec::SmallVec;
use std::hash::{Hash, Hasher};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
@@ -132,10 +147,17 @@ pub(crate) struct A11y {
pub(crate) focus_ids: FxHashMap<NodeId, FocusId>,
pub(crate) node_bounds: FxHashMap<NodeId, Bounds<Pixels>>,
pub(crate) action_listeners: FxHashMap<NodeId, Vec<(Action, A11yActionListener)>>,
/// The window's title, used to label the root node so assistive
/// technology can tell windows apart.
window_title: Option<SharedString>,
}
impl A11y {
pub(crate) fn new(active_flag: Arc<AtomicBool>, force_disabled: bool) -> Self {
pub(crate) fn new(
active_flag: Arc<AtomicBool>,
force_disabled: bool,
window_title: Option<SharedString>,
) -> Self {
Self {
force_disabled,
active_flag,
@@ -144,9 +166,14 @@ impl A11y {
focus_ids: FxHashMap::default(),
node_bounds: FxHashMap::default(),
action_listeners: FxHashMap::default(),
window_title,
}
}
pub(crate) fn set_window_title(&mut self, title: impl Into<SharedString>) {
self.window_title = Some(title.into());
}
/// Ensures that [`Self::is_active`] returns up to date information.
///
/// See the docs for [`Self::active_flag`] and [`Self::active_this_frame`]
@@ -159,31 +186,99 @@ impl A11y {
self.active_this_frame
}
pub(crate) fn set_focusable(&mut self, node_id: NodeId, focus_id: FocusId) {
self.focus_ids.insert(node_id, focus_id);
}
/// Report `node_id` as the currently-focused node, if it is present in the
/// tree.
///
/// Must only be called once per frame.
pub(crate) fn set_focus(&mut self, node_id: NodeId) {
// A focused node must have been registered as focusable this frame.
if !self.focus_ids.contains_key(&node_id) {
if cfg!(debug_assertions) {
panic!("set_focus called for a node that was not registered with set_focusable");
} else {
log::warn!(
"a11y: set_focus called for a node that was not registered with \
set_focusable ({node_id:?})"
);
}
}
if self.nodes.has_node(node_id) {
self.nodes.set_focus(node_id);
}
}
pub(crate) fn set_active_descendant(&mut self, node_id: NodeId) {
// The active descendant must be a descendant of the focused container,
// not the focused node itself.
if self.nodes.node_is_focused(node_id) {
if cfg!(debug_assertions) {
panic!("set_active_descendant called on the focused node");
} else {
log::warn!("a11y: set_active_descendant called on the focused node ({node_id:?})");
}
return;
}
if self.nodes.has_node(node_id) && self.nodes.focus_is_ancestor_of_current() {
self.nodes.set_active_descendant(node_id);
}
}
/// Clear per-frame state and push the root node to start a new frame.
pub(crate) fn begin_frame(&mut self) {
self.focus_ids.clear();
self.node_bounds.clear();
self.action_listeners.clear();
self.nodes.begin_frame();
self.nodes.begin_frame(self.window_title.as_ref());
}
/// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter.
pub(crate) fn end_frame(&mut self) -> TreeUpdate {
let tree_update = self.nodes.finalize();
self.nodes.finalize()
}
}
// Zed currently doesn't set any a11y APIs on *any* UI elements, so a
// tree with nodes other than the root indicates a bug in the
// `TreeUpdate`-producing logic.
//
// Remove this when adding aria attributes.
if tree_update.nodes.len() > 1 {
log::warn!(
"expected an empty a11y tree update (only the root node), but got {} nodes; Zed has no accessible UI elements yet",
tree_update.nodes.len()
);
}
/// Builder API for synthetic children. See the docs for
/// [`Element::a11y_synthetic_children`].
pub struct A11ySubtreeBuilder<'a> {
parent_id: NodeId,
nodes: &'a mut A11yNodeBuilder,
}
tree_update
impl<'a> A11ySubtreeBuilder<'a> {
pub(crate) fn new(parent_id: NodeId, nodes: &'a mut A11yNodeBuilder) -> Self {
Self { parent_id, nodes }
}
/// Derive a [`NodeId`] for a synthetic child.
///
/// The generated ID is based on the hash of `key`, as well as the parent's
/// ID. This means that `key`s must be unique within the same
/// [`Element::a11y_synthetic_children`] call, but may be duplicated across
/// different calls.
pub fn synthetic_node_id(&self, key: impl Hash) -> NodeId {
let mut hasher = std::hash::DefaultHasher::default();
self.parent_id.0.hash(&mut hasher);
key.hash(&mut hasher);
NodeId(hasher.finish())
}
/// Append a synthetic leaf node as a child of this element's node.
///
/// 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)
}
/// A mutable reference to the parent node.
pub fn parent_node(&mut self) -> &mut accesskit::Node {
self.nodes
.current_node_mut()
.expect("A11ySubtreeBuilder exists only while its element's node is on the stack")
}
}
@@ -194,9 +289,14 @@ pub(crate) struct A11yNodeBuilder {
/// `HashMap<NodeId, Node>` to remove the need for `seen_ids`
all_nodes: Vec<(NodeId, accesskit::Node)>,
seen_ids: FxHashSet<NodeId>,
focus: NodeId,
#[cfg(debug_assertions)]
has_set_focus: bool,
/// The node that GPUI considers focused. Note that this may be different to
/// what is reported to accesskit - see [`Self::active_descendant`]
focus: Option<NodeId>,
/// If a node calls `.aria_active_descendant()`, AND an ancestor is focused,
/// override it as the focused node. This supports the "active descendant"
/// pattern, which allows a focused container to act as if a descendant is
/// focused.
active_descendant: Option<NodeId>,
}
impl A11yNodeBuilder {
@@ -206,26 +306,32 @@ impl A11yNodeBuilder {
nodes_stack: SmallVec::new(),
all_nodes: Vec::new(),
seen_ids: FxHashSet::default(),
focus: ROOT_NODE_ID,
#[cfg(debug_assertions)]
has_set_focus: false,
focus: None,
active_descendant: None,
}
}
#[must_use]
fn can_push(&mut self, id: NodeId) -> bool {
debug_assert!(!self.ids_stack.is_empty(), "node pushed before push_root");
if !self.seen_ids.insert(id) {
debug_assert!(
false,
"Duplicate a11y node id: {id:?}. In a release build, this node would be silently discarded from the a11y tree."
);
return false;
}
true
}
/// Push a new node onto the stack. It becomes a child of the current
/// top-of-stack node.
///
/// Returns `true` if the node was successfully pushed.
pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool {
debug_assert!(!self.ids_stack.is_empty(), "push called before push_root");
if !self.seen_ids.insert(id) {
debug_assert!(
false,
"Duplicate a11y node id: {id:?}. In a release build, this node would be silently discarded from the a11y tree."
);
// We need to return `false` here because inserting a duplicate
// node will cause a panic in accesskit
if !self.can_push(id) {
return false;
}
@@ -237,6 +343,27 @@ impl A11yNodeBuilder {
true
}
/// Add a leaf node as a child of the current top-of-stack node, without
/// pushing it onto the stack. Semantically equivalent to a [`Self::push`]
/// followed by a [`Self::pop`].
///
/// Returns `true` if the node was successfully pushed.
pub(crate) fn push_leaf(&mut self, id: NodeId, node: accesskit::Node) -> bool {
if !self.can_push(id) {
return false;
}
if let Some(parent) = self.nodes_stack.last_mut() {
parent.push_child(id);
}
self.all_nodes.push((id, node));
true
}
pub(crate) fn current_node_mut(&mut self) -> Option<&mut accesskit::Node> {
self.nodes_stack.last_mut()
}
/// Pop the current node off the stack and finalize it into the all_nodes
/// list.
pub(crate) fn pop(&mut self) {
@@ -248,20 +375,20 @@ impl A11yNodeBuilder {
}
/// Push the root node to start a new frame.
fn begin_frame(&mut self) {
fn begin_frame(&mut self, window_title: Option<&SharedString>) {
self.all_nodes.clear();
self.ids_stack.clear();
self.nodes_stack.clear();
self.seen_ids.clear();
#[cfg(debug_assertions)]
{
self.has_set_focus = false;
let mut root_node = accesskit::Node::new(accesskit::Role::Window);
if let Some(title) = window_title {
root_node.set_label(title.to_string());
}
let root_node = accesskit::Node::new(accesskit::Role::Window);
self.ids_stack.push(ROOT_NODE_ID);
self.nodes_stack.push(root_node);
self.focus = ROOT_NODE_ID;
self.focus = None;
self.active_descendant = None;
}
/// Returns whether a node with the given ID has been pushed in this frame.
@@ -269,17 +396,51 @@ impl A11yNodeBuilder {
id == ROOT_NODE_ID || self.seen_ids.contains(&id)
}
/// Set the focused node for this frame.
pub(crate) fn set_focus(&mut self, id: NodeId) {
#[cfg(debug_assertions)]
/// Returns whether `id` is the node currently reported as focused.
pub(crate) fn node_is_focused(&self, id: NodeId) -> bool {
self.focus == Some(id)
}
pub(crate) fn focus_is_ancestor_of_current(&self) -> bool {
let Some(focus) = self.focus else {
return false;
};
// The current node is on top of the stack; everything below it is an
// ancestor.
let ancestor_count = self.ids_stack.len().saturating_sub(1);
self.ids_stack[..ancestor_count].contains(&focus)
}
pub(crate) fn set_active_descendant(&mut self, id: NodeId) {
if self
.active_descendant
.is_some_and(|existing| existing != id)
{
debug_assert!(
!self.has_set_focus,
"set_focus called more than once in a single frame"
);
self.has_set_focus = true;
if cfg!(debug_assertions) {
panic!("active descendant claimed by multiple nodes in one frame");
} else {
log::warn!(
"a11y: multiple nodes claimed the active descendant this frame; \
using last-wins ({id:?})"
);
}
}
self.focus = id;
self.active_descendant = Some(id);
}
pub(crate) fn set_focus(&mut self, id: NodeId) {
if self.focus.is_some() {
if cfg!(debug_assertions) {
panic!("set_focus called more than once in a single frame");
} else {
log::warn!(
"a11y: set_focus called more than once in a single frame; \
using last-wins ({id:?})"
);
}
}
self.focus = Some(id);
}
fn finalize(&mut self) -> TreeUpdate {
@@ -302,12 +463,26 @@ impl A11yNodeBuilder {
}
}
let focus = match self.active_descendant {
Some(id) if self.has_node(id) => id,
Some(id) => {
if cfg!(debug_assertions) {
panic!("active_descendant set to {id:?}, which is not in the tree");
} else {
log::warn!("active_descendant set to {id:?}, which is not in the tree");
self.focus.unwrap_or(ROOT_NODE_ID)
}
}
_ => self.focus.unwrap_or(ROOT_NODE_ID),
};
let nodes = std::mem::take(&mut self.all_nodes);
let update = TreeUpdate {
nodes,
tree: Some(accesskit::Tree::new(ROOT_NODE_ID)),
tree_id: accesskit::TreeId::ROOT,
focus: self.focus,
focus,
};
Self::repair_tree_update(update)
@@ -359,3 +534,261 @@ impl A11yNodeBuilder {
update
}
}
#[cfg(test)]
mod tests {
// Import specific items rather than glob-importing `super`, which would pull
// in gpui's own `test` attribute macro and shadow the standard one.
use super::{A11y, A11yNodeBuilder, ROOT_NODE_ID};
use crate::FocusId;
use accesskit::{NodeId, Role};
use std::sync::{Arc, atomic::AtomicBool};
fn test_node() -> accesskit::Node {
accesskit::Node::new(Role::GenericContainer)
}
fn new_builder() -> A11yNodeBuilder {
let mut builder = A11yNodeBuilder::new();
builder.begin_frame(None);
builder
}
fn new_a11y() -> A11y {
let mut a11y = A11y::new(Arc::new(AtomicBool::new(true)), false, None);
a11y.begin_frame();
a11y
}
#[test]
fn active_descendant_honored_when_container_focused() {
let mut builder = new_builder();
let container = NodeId(1);
let item = NodeId(2);
assert!(builder.push(container, test_node()));
builder.set_focus(container);
assert!(builder.push(item, test_node()));
// The item is on top of the stack; the focused container is its
// ancestor, so the claim is honored.
assert!(builder.focus_is_ancestor_of_current());
builder.set_active_descendant(item);
builder.pop(); // item
builder.pop(); // container
let update = builder.finalize();
assert_eq!(update.focus, item);
}
#[test]
fn active_descendant_honored_for_deep_descendant() {
let mut builder = new_builder();
let container = NodeId(1);
let group = NodeId(2);
let item = NodeId(3);
assert!(builder.push(container, test_node()));
builder.set_focus(container);
assert!(builder.push(group, test_node()));
assert!(builder.push(item, test_node()));
// The item is a grandchild of the focused container; depth doesn't
// matter, the focused ancestor is still on the stack.
assert!(builder.focus_is_ancestor_of_current());
builder.set_active_descendant(item);
builder.pop(); // item
builder.pop(); // group
builder.pop(); // container
let update = builder.finalize();
assert_eq!(update.focus, item);
}
#[test]
fn active_descendant_ignored_when_focus_in_other_subtree() {
let mut builder = new_builder();
let focused_container = NodeId(1);
let focused_leaf = NodeId(2);
let other_container = NodeId(3);
let other_item = NodeId(4);
// First subtree holds real focus.
assert!(builder.push(focused_container, test_node()));
assert!(builder.push(focused_leaf, test_node()));
builder.set_focus(focused_leaf);
builder.pop(); // focused_leaf
builder.pop(); // focused_container
// Second subtree: its item would claim the active descendant, but the
// focus is not on any of its ancestors, so the gate rejects it.
assert!(builder.push(other_container, test_node()));
assert!(builder.push(other_item, test_node()));
assert!(!builder.focus_is_ancestor_of_current());
builder.pop(); // other_item
builder.pop(); // other_container
let update = builder.finalize();
assert_eq!(update.focus, focused_leaf);
}
#[test]
fn active_descendant_ignored_when_nothing_focused() {
let mut builder = new_builder();
let container = NodeId(1);
let item = NodeId(2);
assert!(builder.push(container, test_node()));
assert!(builder.push(item, test_node()));
// Nothing is focused (focus defaults to the root window node), so the
// gate rejects the claim.
assert!(!builder.focus_is_ancestor_of_current());
builder.pop();
builder.pop();
let update = builder.finalize();
assert_eq!(update.focus, ROOT_NODE_ID);
}
#[test]
fn regular_focus_used_when_no_active_descendant() {
let mut builder = new_builder();
let focused = NodeId(1);
assert!(builder.push(focused, test_node()));
builder.set_focus(focused);
builder.pop();
let update = builder.finalize();
assert_eq!(update.focus, focused);
}
#[test]
fn focus_is_ancestor_excludes_self_and_non_ancestors() {
let mut builder = new_builder();
let container = NodeId(1);
let item = NodeId(2);
assert!(builder.push(container, test_node()));
builder.set_focus(container);
// With the focused container itself on top, it is not its own (strict)
// ancestor, so the gate is false.
assert!(!builder.focus_is_ancestor_of_current());
assert!(builder.push(item, test_node()));
// Now the focused container is a strict ancestor of the item on top.
assert!(builder.focus_is_ancestor_of_current());
builder.pop();
builder.pop();
}
// The double-claim guard panics only in debug builds; in release it falls
// back to last-wins with a warning.
#[test]
#[cfg_attr(
debug_assertions,
should_panic(expected = "active descendant claimed by multiple nodes")
)]
fn multiple_active_descendant_claims_panic_in_debug() {
let mut builder = new_builder();
builder.set_active_descendant(NodeId(1));
builder.set_active_descendant(NodeId(2));
}
// Setting focus twice in one frame means two elements both claimed window
// focus; that panics in debug and falls back to last-wins in release.
#[test]
#[cfg_attr(
debug_assertions,
should_panic(expected = "set_focus called more than once")
)]
fn setting_focus_twice_panics_in_debug() {
let mut builder = new_builder();
builder.set_focus(NodeId(1));
builder.set_focus(NodeId(2));
}
// Focusing a node that was never registered as focusable is a bug: panic in
// debug, warn in release.
#[test]
#[cfg_attr(
debug_assertions,
should_panic(expected = "was not registered with set_focusable")
)]
fn set_focus_without_set_focusable() {
let mut a11y = new_a11y();
let node = NodeId(1);
assert!(a11y.nodes.push(node, test_node()));
// set_focusable was never called for `node`.
a11y.set_focus(node);
}
// The focused node cannot also be its own active descendant: panic in
// debug, warn in release.
#[test]
#[cfg_attr(debug_assertions, should_panic(expected = "on the focused node"))]
fn set_active_descendant_on_focused_node() {
let mut a11y = new_a11y();
let node = NodeId(1);
assert!(a11y.nodes.push(node, test_node()));
a11y.set_focusable(node, FocusId::default());
a11y.set_focus(node);
a11y.set_active_descendant(node);
}
// Two sibling children of a focused container both claim the active
// descendant (both pass the focus gate). The second claim is a bug: panic
// in debug, last-wins + warn in release.
#[test]
#[cfg_attr(
debug_assertions,
should_panic(expected = "active descendant claimed by multiple nodes")
)]
fn two_siblings_claiming_active_descendant() {
let mut a11y = new_a11y();
let container = NodeId(1);
let first = NodeId(2);
let second = NodeId(3);
assert!(a11y.nodes.push(container, test_node()));
a11y.set_focusable(container, FocusId::default());
a11y.set_focus(container);
assert!(a11y.nodes.push(first, test_node()));
a11y.set_active_descendant(first);
a11y.nodes.pop(); // first
assert!(a11y.nodes.push(second, test_node()));
a11y.set_active_descendant(second);
a11y.nodes.pop(); // second
a11y.nodes.pop(); // container
}
// Node A is focused; node C (a child of the unfocused node B) claims the
// active descendant. The final tree must still report A as focused.
#[test]
fn active_descendant_in_unfocused_subtree_keeps_real_focus() {
let mut a11y = new_a11y();
let a = NodeId(1);
let b = NodeId(2);
let c = NodeId(3);
assert!(a11y.nodes.push(a, test_node()));
a11y.set_focusable(a, FocusId::default());
a11y.set_focus(a);
a11y.nodes.pop(); // a
assert!(a11y.nodes.push(b, test_node()));
assert!(a11y.nodes.push(c, test_node()));
a11y.set_active_descendant(c);
a11y.nodes.pop(); // c
a11y.nodes.pop(); // b
let update = a11y.end_frame();
assert_eq!(update.focus, a);
}
}
+11 -1
View File
@@ -1,6 +1,6 @@
use std::rc::Rc;
use gpui::{App, ElementId, IntoElement, RenderOnce};
use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString};
use heck::ToTitleCase as _;
use ui::{
ButtonSize, ContextMenu, Disableable as _, DropdownMenu, DropdownStyle, FluentBuilder as _,
@@ -19,6 +19,7 @@ where
should_do_title_case: bool,
tab_index: Option<isize>,
disabled: bool,
aria_label: Option<SharedString>,
on_change: Rc<dyn Fn(T, &mut ui::Window, &mut App) + 'static>,
}
@@ -41,6 +42,7 @@ where
should_do_title_case: true,
tab_index: None,
disabled: false,
aria_label: None,
on_change: Rc::new(on_change),
}
}
@@ -59,6 +61,13 @@ where
self.disabled = disabled;
self
}
/// Sets the label announced by assistive technology.
/// Defaults to the currently selected value's label.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.aria_label = Some(label.into());
self
}
}
impl<T> RenderOnce for EnumVariantDropdown<T>
@@ -104,6 +113,7 @@ where
},
context_menu,
)
.when_some(self.aria_label, |this, label| this.aria_label(label))
.disabled(self.disabled)
.when_some(self.tab_index, |elem, tab_index| elem.tab_index(tab_index))
.trigger_size(ButtonSize::Medium)
+344 -86
View File
@@ -1,14 +1,18 @@
use std::rc::Rc;
use editor::Editor;
use gpui::{AnyElement, ElementId, Focusable, TextStyleRefinement};
use editor::{Editor, MultiBufferOffset};
use gpui::{
A11ySubtreeBuilder, AccessibleAction, AnyElement, ElementId, Entity, Focusable, Role,
TextStyleRefinement,
accesskit::{self, ActionData},
};
use settings::Settings as _;
use theme_settings::ThemeSettings;
use ui::{Tooltip, prelude::*, rems};
#[derive(IntoElement)]
pub struct SettingsInputField {
id: Option<ElementId>,
id: ElementId,
initial_text: Option<String>,
placeholder: Option<&'static str>,
confirm: Option<Rc<dyn Fn(Option<String>, &mut Window, &mut App)>>,
@@ -20,12 +24,19 @@ pub struct SettingsInputField {
confirm_on_focus_out: bool,
action_slot: Option<AnyElement>,
color: Option<Color>,
aria_label: Option<SharedString>,
}
impl SettingsInputField {
pub fn new() -> Self {
/// Creates a new input field.
///
/// The `id` must be unique among sibling elements: it keys the underlying
/// editor's state across frames and identifies this field in the
/// accessibility tree. Derive it from data unique to the setting being
/// edited (e.g. its JSON path).
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: None,
id: id.into(),
initial_text: None,
placeholder: None,
confirm: None,
@@ -37,14 +48,10 @@ impl SettingsInputField {
confirm_on_focus_out: false,
action_slot: None,
color: None,
aria_label: None,
}
}
pub fn with_id(mut self, id: impl Into<ElementId>) -> Self {
self.id = Some(id.into());
self
}
pub fn with_initial_text(mut self, initial_text: String) -> Self {
self.initial_text = Some(initial_text);
self
@@ -102,6 +109,13 @@ impl SettingsInputField {
self.color = Some(color);
self
}
/// Sets the label announced by assistive technology.
/// Defaults to the placeholder text, if any.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.aria_label = Some(label.into());
self
}
}
impl RenderOnce for SettingsInputField {
@@ -116,83 +130,48 @@ impl RenderOnce for SettingsInputField {
..Default::default()
};
let first_render_initial_text = window.use_state(cx, |_, _| self.initial_text.clone());
let first_render_initial_text = window.use_keyed_state(
(self.id.clone(), "first-render-initial-text"),
cx,
|_, _| self.initial_text.clone(),
);
let editor = if let Some(id) = self.id {
window.use_keyed_state(id, cx, {
let initial_text = self.initial_text.clone();
let placeholder = self.placeholder;
let mut confirm = self.confirm.clone();
let editor = window.use_keyed_state((self.id.clone(), "editor"), cx, {
let initial_text = self.initial_text.clone();
let placeholder = self.placeholder;
let mut confirm = self.confirm.clone();
move |window, cx| {
let mut editor = Editor::single_line(window, cx);
let editor_focus_handle = editor.focus_handle(cx);
if let Some(text) = initial_text {
editor.set_text(text, window, cx);
}
if let Some(confirm) = confirm.take()
&& (self.confirm_on_focus_out
|| (!self.display_confirm_button
&& !self.display_clear_button
&& !self.clear_on_confirm))
{
cx.on_focus_out(
&editor_focus_handle,
window,
move |editor, _, window, cx| {
let text = Some(editor.text(cx));
confirm(text, window, cx);
},
)
.detach();
}
if let Some(placeholder) = placeholder {
editor.set_placeholder_text(placeholder, window, cx);
}
editor.set_text_style_refinement(styles);
editor
move |window, cx| {
let mut editor = Editor::single_line(window, cx);
let editor_focus_handle = editor.focus_handle(cx);
if let Some(text) = initial_text {
editor.set_text(text, window, cx);
}
})
} else {
window.use_state(cx, {
let initial_text = self.initial_text.clone();
let placeholder = self.placeholder;
let mut confirm = self.confirm.clone();
move |window, cx| {
let mut editor = Editor::single_line(window, cx);
let editor_focus_handle = editor.focus_handle(cx);
if let Some(text) = initial_text {
editor.set_text(text, window, cx);
}
if let Some(confirm) = confirm.take()
&& (self.confirm_on_focus_out
|| (!self.display_confirm_button
&& !self.display_clear_button
&& !self.clear_on_confirm))
{
cx.on_focus_out(
&editor_focus_handle,
window,
move |editor, _, window, cx| {
let text = Some(editor.text(cx));
confirm(text, window, cx);
},
)
.detach();
}
if let Some(placeholder) = placeholder {
editor.set_placeholder_text(placeholder, window, cx);
}
editor.set_text_style_refinement(styles);
editor
if let Some(confirm) = confirm.take()
&& (self.confirm_on_focus_out
|| (!self.display_confirm_button
&& !self.display_clear_button
&& !self.clear_on_confirm))
{
cx.on_focus_out(
&editor_focus_handle,
window,
move |editor, _, window, cx| {
let text = Some(editor.text(cx));
confirm(text, window, cx);
},
)
.detach();
}
})
};
if let Some(placeholder) = placeholder {
editor.set_placeholder_text(placeholder, window, cx);
}
editor.set_text_style_refinement(styles);
editor
}
});
let is_editor_focused = editor.read(cx).is_focused(window);
let editor_text = editor.read(cx).text(cx);
@@ -224,14 +203,49 @@ impl RenderOnce for SettingsInputField {
let clear_on_confirm = self.clear_on_confirm;
let clear_on_confirm_for_button = self.clear_on_confirm;
let theme_colors = cx.theme().colors();
let display_confirm_button = self.display_confirm_button;
let display_clear_button = self.display_clear_button;
let confirm_for_button = self.confirm.clone();
let is_editor_empty = editor_text.trim().is_empty();
let aria_label = self
.aria_label
.or_else(|| self.placeholder.map(SharedString::new_static));
let (a11y_value, a11y_text_runs) =
text_field_a11y_state(self.id.clone(), &editor, window, cx);
let theme_colors = cx.theme().colors();
h_flex()
.id(self.id.clone())
.role(Role::TextInput)
.when_some(aria_label, |this, label| this.aria_label(label))
.aria_value(a11y_value)
.when_some(self.placeholder, |this, placeholder| {
this.aria_placeholder(placeholder)
})
.a11y_synthetic_children(a11y_text_runs)
.on_a11y_action(AccessibleAction::SetValue, {
let weak_editor = editor.downgrade();
let confirm = self.confirm.clone();
move |data, window, cx| {
let Some(ActionData::Value(text)) = data else {
return;
};
let Some(editor) = weak_editor.upgrade() else {
return;
};
let text = text.to_string();
editor.update(cx, |editor, cx| {
editor.set_text(text.clone(), window, cx);
});
if let Some(confirm) = confirm.as_ref() {
let new_value = (!text.is_empty()).then_some(text);
confirm(new_value, window, cx);
}
}
})
.group("settings-input-field-editor")
.relative()
.py_1()
@@ -242,8 +256,13 @@ impl RenderOnce for SettingsInputField {
.border_1()
.border_color(theme_colors.border)
.bg(theme_colors.editor_background)
.when_some(self.tab_index, |this, tab_index| {
let focus_handle = editor.focus_handle(cx).tab_index(tab_index).tab_stop(true);
.map(|this| {
let focus_handle = editor.focus_handle(cx);
let focus_handle = if let Some(tab_index) = self.tab_index {
focus_handle.tab_index(tab_index).tab_stop(true)
} else {
focus_handle
};
this.track_focus(&focus_handle)
.focus(|s| s.border_color(theme_colors.border_focused))
})
@@ -263,6 +282,7 @@ impl RenderOnce for SettingsInputField {
IconButton::new("clear-button", IconName::Close)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.aria_label("Clear")
.tooltip(Tooltip::text("Clear"))
.on_click(move |_, window, cx| {
let Some(editor) = weak_editor_for_clear.upgrade() else {
@@ -282,6 +302,7 @@ impl RenderOnce for SettingsInputField {
IconButton::new("confirm-button", IconName::Check)
.icon_size(IconSize::Small)
.icon_color(Color::Success)
.aria_label("Confirm")
.tooltip(Tooltip::text("Enter to Confirm"))
.on_click(move |_, window, cx| {
let Some(confirm) = confirm_for_button.as_ref() else {
@@ -325,3 +346,240 @@ impl RenderOnce for SettingsInputField {
})
}
}
/// Compute the shared accessibility state for a focusable wrapper around a
/// single-line [`Editor`]:
///
/// - The value to report via `aria_value`. While the editor is focused this
/// is frozen at its focus-time content: screen readers announce the full value
/// on every change of a focused control, which would re-read the whole content
/// on each keystroke. The snapshot re-syncs on blur.
/// - A closure for `a11y_synthetic_children` exposing the editor's live text
/// and selection as AccessKit text runs, enabling the platform text
/// pattern (caret tracking, review commands, typed-character echo).
///
/// The caller must also give the element an id, a text input role (e.g.
/// [`Role::TextInput`]), a label, and track the editor's focus handle.
///
/// All work is skipped when accessibility is inactive (no assistive
/// technology connected), since the results are only observable through the
/// accessibility tree.
///
/// Note: much of this may want
pub(crate) fn text_field_a11y_state(
state_key: impl Into<ElementId>,
editor: &Entity<Editor>,
window: &mut Window,
cx: &mut App,
) -> (String, impl FnOnce(&mut A11ySubtreeBuilder) + 'static) {
let state = window.is_a11y_active().then(|| {
let (text, selection_head, selection_tail) = editor.update(cx, |editor, cx| {
let display_snapshot = editor.display_snapshot(cx);
let selection = editor
.selections
.newest::<MultiBufferOffset>(&display_snapshot);
(editor.text(cx), selection.head().0, selection.tail().0)
});
let is_focused = editor.read(cx).is_focused(window);
let a11y_value = window.use_keyed_state((state_key.into(), "a11y-value"), cx, {
let text = text.clone();
move |_, _| text
});
if !is_focused && *a11y_value.read(cx) != text {
*a11y_value.as_mut(cx) = text.clone();
}
let frozen_value = a11y_value.read(cx).clone();
(frozen_value, text, selection_head, selection_tail)
});
let (frozen_value, run_data) = match state {
Some((frozen_value, text, selection_head, selection_tail)) => {
(frozen_value, Some((text, selection_head, selection_tail)))
}
None => (String::new(), None),
};
let text_runs = move |builder: &mut A11ySubtreeBuilder| {
if let Some((text, selection_head, selection_tail)) = run_data {
push_a11y_text_runs(builder, &text, selection_tail, selection_head);
}
};
(frozen_value, text_runs)
}
/// AccessKit's `word_starts` uses `u8` indices, so a single text run cannot
/// exceed this many characters. Longer text is split into multiple runs.
const MAX_CHARS_PER_TEXT_RUN: usize = 255;
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn char_index_for_byte(text: &str, byte_offset: usize) -> usize {
text.char_indices()
.take_while(|(byte_ix, _)| *byte_ix < byte_offset)
.count()
}
/// Convert a character index into an AccessKit text position, accounting for
/// text that is split into multiple runs.
///
/// `synthetic_node_id` maps a chunk index to the run's node id (in practice
/// [`A11ySubtreeBuilder::synthetic_node_id`]); it is a parameter so this
/// arithmetic can be property-tested without constructing a builder.
fn a11y_text_position(
char_index: usize,
synthetic_node_id: impl Fn(u64) -> accesskit::NodeId,
) -> accesskit::TextPosition {
// A position landing exactly on a chunk boundary refers to the end of the
// previous chunk rather than the start of the next one.
let chunk_index = if char_index > 0 && char_index.is_multiple_of(MAX_CHARS_PER_TEXT_RUN) {
char_index / MAX_CHARS_PER_TEXT_RUN - 1
} else {
char_index / MAX_CHARS_PER_TEXT_RUN
};
accesskit::TextPosition {
node: synthetic_node_id(chunk_index as u64),
character_index: char_index - chunk_index * MAX_CHARS_PER_TEXT_RUN,
}
}
/// Split `text` into AccessKit text runs (chunked small enough that per-run
/// character indices fit AccessKit's `u8`-indexed `word_starts`), and compute
/// the text selection for the given byte offsets.
///
/// `synthetic_node_id` maps a chunk index to that run's node id. Returns the
/// runs in order plus the selection, leaving it to the caller to push them —
/// this keeps the logic free of [`A11ySubtreeBuilder`] so it can be
/// property-tested against arbitrary strings.
///
/// `selection_tail` and `selection_head` are byte offsets into `text`.
fn build_a11y_text_runs(
text: &str,
selection_tail: usize,
selection_head: usize,
synthetic_node_id: impl Fn(u64) -> accesskit::NodeId,
) -> (
Vec<(accesskit::NodeId, accesskit::Node)>,
accesskit::TextSelection,
) {
let chars: Vec<char> = text.chars().collect();
let total_chars = chars.len();
// Build at least one (possibly empty) run so the text pattern remains
// supported when the field is empty.
let num_chunks = total_chars.div_ceil(MAX_CHARS_PER_TEXT_RUN).max(1);
let mut word_starts = Vec::new();
let mut was_word_char = false;
for (ix, c) in chars.iter().enumerate() {
let is_word = is_word_char(*c);
if is_word && !was_word_char {
word_starts.push(ix);
}
was_word_char = is_word;
}
let mut runs = Vec::with_capacity(num_chunks);
for chunk_index in 0..num_chunks {
let char_start = chunk_index * MAX_CHARS_PER_TEXT_RUN;
let char_end = (char_start + MAX_CHARS_PER_TEXT_RUN).min(total_chars);
let chunk_chars = &chars[char_start..char_end];
let mut node = accesskit::Node::new(accesskit::Role::TextRun);
node.set_text_direction(accesskit::TextDirection::LeftToRight);
node.set_value(chunk_chars.iter().collect::<String>());
node.set_character_lengths(
chunk_chars
.iter()
.map(|c| c.len_utf8() as u8)
.collect::<Vec<u8>>(),
);
node.set_word_starts(
word_starts
.iter()
.filter(|&&word_start| word_start >= char_start && word_start < char_end)
.map(|&word_start| (word_start - char_start) as u8)
.collect::<Vec<u8>>(),
);
if chunk_index > 0 {
node.set_previous_on_line(synthetic_node_id(chunk_index as u64 - 1));
}
if chunk_index + 1 < num_chunks {
node.set_next_on_line(synthetic_node_id(chunk_index as u64 + 1));
}
runs.push((synthetic_node_id(chunk_index as u64), node));
}
let anchor = a11y_text_position(
char_index_for_byte(text, selection_tail),
&synthetic_node_id,
);
let focus = a11y_text_position(
char_index_for_byte(text, selection_head),
&synthetic_node_id,
);
(runs, accesskit::TextSelection { anchor, focus })
}
/// Expose the field's text content as AccessKit text runs, plus the current
/// selection/caret, enabling the platform's text pattern (e.g. UIA
/// TextPattern on Windows) so screen readers can track the caret, review the
/// text, and handle typed-character echo natively.
///
/// `selection_tail` and `selection_head` are byte offsets into `text`.
fn push_a11y_text_runs(
builder: &mut A11ySubtreeBuilder,
text: &str,
selection_tail: usize,
selection_head: usize,
) {
let (runs, selection) = build_a11y_text_runs(text, selection_tail, selection_head, |chunk| {
builder.synthetic_node_id(chunk)
});
for (id, node) in runs {
builder.push_child(id, node);
}
builder.parent_node().set_text_selection(selection);
}
#[cfg(test)]
mod tests {
use super::build_a11y_text_runs;
use gpui::accesskit::NodeId;
use gpui::proptest::strategy::Strategy;
/// A strategy producing strings with a deliberate mix of character
/// categories — ASCII, Latin accents, Cyrillic, Arabic, CJK, emoji, and
/// arbitrary scalars — so run-splitting is exercised across scripts and
/// byte widths (1–4 UTF-8 bytes). Lengths reach past one chunk (255 chars).
fn arbitrary_text() -> impl Strategy<Value = String> {
let character = gpui::proptest::prop_oneof![
gpui::proptest::char::range(' ', '~'), // ASCII printable
gpui::proptest::char::range('\u{00A1}', '\u{00FF}'), // Latin-1 (accents)
gpui::proptest::char::range('\u{0100}', '\u{024F}'), // Latin Extended-A/B
gpui::proptest::char::range('\u{0400}', '\u{04FF}'), // Cyrillic
gpui::proptest::char::range('\u{0600}', '\u{06FF}'), // Arabic
gpui::proptest::char::range('\u{4E00}', '\u{9FFF}'), // CJK Unified Ideographs
gpui::proptest::char::range('\u{1F300}', '\u{1FAFF}'), // emoji & pictographs
gpui::proptest::char::any(), // anything else
];
gpui::proptest::collection::vec(character, 0..600)
.prop_map(|chars| chars.into_iter().collect::<String>())
}
/// Splitting an arbitrary string into AccessKit text runs must never panic,
/// for any text and any byte selection offsets — including empty text, text
/// spanning multiple chunks, multi-byte characters, and offsets past the end.
#[gpui::property_test]
fn building_text_runs_never_panics(
#[strategy = arbitrary_text()] text: String,
selection_tail: usize,
selection_head: usize,
) {
let _ = build_a11y_text_runs(&text, selection_tail, selection_head, NodeId);
}
}
+128 -26
View File
@@ -7,8 +7,8 @@ use std::{
use editor::Editor;
use gpui::{
ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers, TextAlign,
TextStyleRefinement, WeakEntity,
AccessibleAction, ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers, Role,
TextAlign, TextStyleRefinement, WeakEntity,
};
use settings::{
@@ -274,6 +274,7 @@ pub struct NumberField<T: NumberFieldType = usize> {
on_reset: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_change: Rc<dyn Fn(&T, &mut Window, &mut App) + 'static>,
tab_index: Option<isize>,
aria_label: Option<SharedString>,
}
impl<T: NumberFieldType> NumberField<T> {
@@ -314,6 +315,7 @@ impl<T: NumberFieldType> NumberField<T> {
on_reset: None,
on_change: Rc::new(|_, _, _| {}),
tab_index: None,
aria_label: None,
}
}
@@ -342,6 +344,12 @@ impl<T: NumberFieldType> NumberField<T> {
self
}
/// Sets the label announced by assistive technology.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.aria_label = Some(label.into());
self
}
fn sync_on_change_state(&self, cx: &mut App) {
self.on_change_state
.update(cx, |state, _| *state = Some(self.on_change.clone()));
@@ -354,6 +362,22 @@ enum ValueChangeDirection {
Decrement,
}
/// Best-effort conversion of a numeric field value to `f64` for reporting to
/// assistive technology. Goes through the `Display` representation because
/// `NumberFieldType` has no general numeric conversion.
fn a11y_numeric_value(value: &impl Display) -> Option<f64> {
format!("{}", value).parse::<f64>().ok()
}
/// Best-effort conversion of an assistive-technology-provided `f64` back into
/// a field value. Falls back to the rounded value for integer field types.
fn a11y_value_to_field_value<T: NumberFieldType>(value: f64) -> Option<T> {
format!("{}", value)
.parse::<T>()
.ok()
.or_else(|| format!("{}", value.round()).parse::<T>().ok())
}
impl<T: NumberFieldType> RenderOnce for NumberField<T> {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
// Sync the on_change callback to Entity state so focus_out handlers can access it
@@ -437,6 +461,40 @@ impl<T: NumberFieldType> RenderOnce for NumberField<T> {
})
};
let step_value = {
let on_change = self.on_change.clone();
let get_current_value = get_current_value.clone();
let update_editor_text = update_editor_text.clone();
Rc::new(
move |direction: ValueChangeDirection,
modifiers: Modifiers,
window: &mut Window,
cx: &mut App| {
let current_value = get_current_value(cx);
let step = get_step(modifiers);
let new_value = change_value(current_value, step, direction);
update_editor_text(new_value, window, cx);
on_change(&new_value, window, cx);
},
)
};
let set_value = {
let on_change = self.on_change.clone();
let update_editor_text = update_editor_text.clone();
move |raw_value: f64, window: &mut Window, cx: &mut App| {
let Some(parsed) = a11y_value_to_field_value::<T>(raw_value) else {
return;
};
let new_value = clamp_value(parsed);
update_editor_text(new_value, window, cx);
on_change(&new_value, window, cx);
}
};
let bg_color = cx.theme().colors().surface_background;
let hover_bg_color = cx.theme().colors().element_hover;
@@ -460,43 +518,86 @@ impl<T: NumberFieldType> RenderOnce for NumberField<T> {
h_flex()
.id(self.id.clone())
.role(Role::SpinButton)
.when_some(self.aria_label.clone(), |this, label| {
this.aria_label(label)
})
.when_some(a11y_numeric_value(&self.value), |this, value| {
this.aria_numeric_value(value)
})
.when_some(a11y_numeric_value(&self.min_value), |this, min| {
this.aria_min_numeric_value(min)
})
.when_some(a11y_numeric_value(&self.max_value), |this, max| {
this.aria_max_numeric_value(max)
})
.when_some(a11y_numeric_value(&self.step), |this, step| {
this.aria_numeric_value_step(step)
})
// Some assistive technology (e.g. Orca via AT-SPI's Value
// interface) can only set an absolute value, while other screen
// readers dispatch Increment/Decrement, so support all three.
.on_a11y_action(AccessibleAction::SetValue, {
move |data, window, cx| {
if let Some(gpui::accesskit::ActionData::NumericValue(value)) = data {
set_value(*value, window, cx);
}
}
})
.on_a11y_action(AccessibleAction::Increment, {
let step_value = step_value.clone();
move |_, window, cx| {
step_value(
ValueChangeDirection::Increment,
Modifiers::default(),
window,
cx,
);
}
})
.on_a11y_action(AccessibleAction::Decrement, {
let step_value = step_value.clone();
move |_, window, cx| {
step_value(
ValueChangeDirection::Decrement,
Modifiers::default(),
window,
cx,
);
}
})
.track_focus(&self.focus_handle)
.gap_1()
.when_some(self.on_reset, |this, on_reset| {
this.child(
IconButton::new("reset", IconName::RotateCcw)
.icon_size(IconSize::Small)
.aria_label("Reset to Default")
.when_some(self.tab_index, |this, _| this.tab_index(0isize))
.on_click(on_reset),
)
})
.child({
let on_change_for_increment = self.on_change.clone();
h_flex()
.map(|decrement| {
let decrement_handler = {
let on_change = self.on_change.clone();
let get_current_value = get_current_value.clone();
let update_editor_text = update_editor_text.clone();
let step_value = step_value.clone();
move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
let current_value = get_current_value(cx);
let step = get_step(click.modifiers());
let new_value = change_value(
current_value,
step,
step_value(
ValueChangeDirection::Decrement,
click.modifiers(),
window,
cx,
);
update_editor_text(new_value, window, cx);
on_change(&new_value, window, cx);
}
};
decrement.child(
base_button(IconName::Dash)
.id((self.id.clone(), "decrement_button"))
.role(Role::Button)
.aria_label("Decrement")
.rounded_tl_sm()
.rounded_bl_sm()
.when_some(self.tab_index, |this, _| this.tab_index(0isize))
@@ -668,6 +769,11 @@ impl<T: NumberFieldType> RenderOnce for NumberField<T> {
};
h_flex()
.id((self.id.clone(), "editor"))
.role(Role::TextInput)
.when_some(self.aria_label.clone(), |this, label| {
this.aria_label(label)
})
.flex_1()
.h_full()
.track_focus(&focus_handle)
@@ -687,27 +793,23 @@ impl<T: NumberFieldType> RenderOnce for NumberField<T> {
})
.map(|increment| {
let increment_handler = {
let on_change = on_change_for_increment.clone();
let get_current_value = get_current_value.clone();
let update_editor_text = update_editor_text.clone();
let step_value = step_value.clone();
move |click: &ClickEvent, window: &mut Window, cx: &mut App| {
let current_value = get_current_value(cx);
let step = get_step(click.modifiers());
let new_value = change_value(
current_value,
step,
step_value(
ValueChangeDirection::Increment,
click.modifiers(),
window,
cx,
);
update_editor_text(new_value, window, cx);
on_change(&new_value, window, cx);
}
};
increment.child(
base_button(IconName::Plus)
.id((self.id.clone(), "increment_button"))
.role(Role::Button)
.aria_label("Increment")
.rounded_tr_sm()
.rounded_br_sm()
.when_some(self.tab_index, |this, _| this.tab_index(0isize))
@@ -1,4 +1,4 @@
use gpui::{IntoElement, ParentElement, Styled};
use gpui::{IntoElement, ParentElement, Role, Styled};
use ui::{Divider, DividerColor, prelude::*};
#[derive(IntoElement)]
@@ -30,12 +30,17 @@ impl SettingsSectionHeader {
impl RenderOnce for SettingsSectionHeader {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let label_text = self.label.clone();
let label = Label::new(self.label)
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx);
v_flex()
.id(label_text.clone())
.role(Role::Heading)
.aria_level(2)
.aria_label(label_text)
.w_full()
.when(!self.no_padding, |this| this.px_8())
.gap_1p5()
@@ -311,9 +311,10 @@ fn render_api_key_provider(
}),
)
.child(
SettingsInputField::new()
SettingsInputField::new(format!("{}-api-key-input", title))
.tab_index(0)
.with_placeholder("xxxxxxxxxxxxxxxxxxxx")
.aria_label(format!("{} API Key", title))
.on_confirm(move |api_key, _window, cx| {
write_key(api_key.filter(|key| !key.is_empty()), cx);
}),
@@ -214,8 +214,7 @@ fn render_host_row(index: usize, host: String, cx: &mut Context<SettingsWindow>)
let host_for_update = host.clone();
let settings_window = cx.entity().downgrade();
SettingsInputField::new()
.with_id(format!("sandbox-host-{}", index))
SettingsInputField::new(format!("sandbox-host-{}", index))
.with_initial_text(host)
.tab_index(0)
.with_buffer_font()
@@ -259,8 +258,7 @@ fn render_host_row(index: usize, host: String, cx: &mut Context<SettingsWindow>)
fn render_add_host_input(cx: &mut Context<SettingsWindow>) -> AnyElement {
let settings_window = cx.entity().downgrade();
SettingsInputField::new()
.with_id("sandbox-host-new")
SettingsInputField::new("sandbox-host-new")
.with_placeholder("Add domain (e.g. github.com or *.npmjs.org)…")
.tab_index(0)
.with_buffer_font()
@@ -299,8 +297,7 @@ fn render_path_row(index: usize, path: PathBuf, cx: &mut Context<SettingsWindow>
let path_for_update = path.clone();
let settings_window = cx.entity().downgrade();
SettingsInputField::new()
.with_id(format!("sandbox-path-{}", index))
SettingsInputField::new(format!("sandbox-path-{}", index))
.with_initial_text(path.to_string_lossy().into_owned())
.tab_index(0)
.with_buffer_font()
@@ -335,8 +332,7 @@ fn render_path_row(index: usize, path: PathBuf, cx: &mut Context<SettingsWindow>
fn render_add_path_input(cx: &mut Context<SettingsWindow>) -> AnyElement {
let settings_window = cx.entity().downgrade();
SettingsInputField::new()
.with_id("sandbox-path-new")
SettingsInputField::new("sandbox-path-new")
.with_placeholder("Add an absolute path (e.g. /path/to/directory)…")
.tab_index(0)
.with_buffer_font()
@@ -977,8 +977,7 @@ fn render_user_pattern_row(
let delete_id = format!("{}-{:?}-delete-{}", tool_id, rule_type, index);
let settings_window = cx.entity().downgrade();
SettingsInputField::new()
.with_id(input_id)
SettingsInputField::new(input_id)
.with_initial_text(pattern)
.tab_index(0)
.with_buffer_font()
@@ -1038,8 +1037,7 @@ fn render_add_pattern_input(
let input_id = format!("{}-{:?}-new-pattern", tool_id, rule_type);
let settings_window = cx.entity().downgrade();
SettingsInputField::new()
.with_id(input_id)
SettingsInputField::new(input_id)
.with_placeholder("Add regex pattern…")
.tab_index(0)
.with_buffer_font()
+97 -17
View File
@@ -10,11 +10,12 @@ use futures::{StreamExt, channel::mpsc};
use fuzzy::StringMatchCandidate;
use gpui::{
Action, App, AsyncApp, ClipboardItem, DEFAULT_ADDITIONAL_WINDOW_SIZE, Div, Entity, FocusHandle,
Focusable, Global, KeyContext, ListState, ReadGlobal as _, ScrollHandle, Stateful,
Focusable, Global, KeyContext, ListState, ReadGlobal as _, Role, ScrollHandle, Stateful,
Subscription, Task, Tiling, TitlebarOptions, UniformListScrollHandle, WeakEntity, Window,
WindowBounds, WindowHandle, WindowOptions, actions, div, list, point, prelude::*, px,
uniform_list,
};
use heck::ToTitleCase as _;
use language::Buffer;
use platform_title_bar::PlatformTitleBar;
@@ -56,7 +57,7 @@ use zed_actions::{
use crate::components::{
EnumVariantDropdown, NumberField, NumberFieldMode, NumberFieldType, SettingsInputField,
SettingsSectionHeader, font_picker, icon_theme_picker, render_ollama_model_picker,
theme_picker,
text_field_a11y_state, theme_picker,
};
use crate::pages::{
CustomAgentForm, McpServerForm, render_input_audio_device_dropdown,
@@ -1148,6 +1149,7 @@ impl SettingsPageItem {
("sub-page".into(), sub_page_link.title.clone()),
"Configure",
)
.aria_label(format!("Configure {}", sub_page_link.title))
.tab_index(0_isize)
.end_icon(
Icon::new(IconName::ChevronRight)
@@ -1322,6 +1324,8 @@ fn render_settings_item_layout(
) -> Stateful<Div> {
h_flex()
.id(title)
.role(Role::Group)
.aria_label(SharedString::new_static(title))
.min_w_0()
.justify_between()
.child(
@@ -1340,6 +1344,7 @@ fn render_settings_item_layout(
IconButton::new("reset-to-default-btn", IconName::Undo)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.aria_label("Reset to Default")
.tooltip(Tooltip::text("Reset to Default"))
.on_click(move |_, window, cx| {
reset_to_default(window, cx);
@@ -1471,6 +1476,7 @@ fn render_settings_item_link(
.icon_color(link_icon_color)
.icon_size(IconSize::Small)
.shape(IconButtonShape::Square)
.aria_label("Copy Link")
.tooltip(Tooltip::text("Copy Link"))
.when_some(json_path, |this, path| {
this.on_click(cx.listener(move |this, _, _, cx| {
@@ -2751,6 +2757,9 @@ impl SettingsWindow {
let edit_in_json_id = SharedString::new(format!("edit-in-json-{}", selected_file_ix));
h_flex()
.id("settings-ui-files-header")
.role(Role::Group)
.aria_label("Settings File")
.w_full()
.gap_1()
.justify_between()
@@ -2888,8 +2897,17 @@ impl SettingsWindow {
// }
// }
fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
fn render_search(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (a11y_value, a11y_text_runs) =
text_field_a11y_state("settings-ui-search", &self.search_bar, window, cx);
h_flex()
.id("settings-ui-search")
.role(Role::SearchInput)
.aria_label("Search Settings")
.aria_value(a11y_value)
.track_focus(&self.search_bar.focus_handle(cx))
.a11y_synthetic_children(a11y_text_runs)
.py_1()
.px_1p5()
.mb_3()
@@ -3059,6 +3077,9 @@ impl SettingsWindow {
.child(self.render_search(window, cx))
.child(
v_flex()
.id("settings-ui-nav")
.role(Role::Tree)
.aria_label("Settings Navigation")
.flex_1()
.overflow_hidden()
.track_focus(&self.navbar_focus_handle.focus_handle(cx))
@@ -3432,7 +3453,11 @@ impl SettingsWindow {
cx: &mut Context<SettingsWindow>,
) -> impl IntoElement {
let current_page_index = self.current_page_index();
let mut page_content = v_flex().id("settings-ui-page").size_full();
let mut page_content = v_flex()
.id("settings-ui-page")
.role(Role::Group)
.aria_label("Settings Content")
.size_full();
let has_active_search = !self.search_bar.read(cx).is_empty(cx);
let has_no_results = self.visible_page_items().next().is_none() && has_active_search;
@@ -4685,6 +4710,12 @@ fn update_project_setting_file(
Ok(())
}
/// 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,
@@ -4733,8 +4764,14 @@ fn render_text_field<T: From<String> + Into<String> + AsRef<str> + Clone>(
.map(|text| text.as_ref().to_string())
};
SettingsInputField::new()
// The JSON path uniquely identifies the setting this field edits, making
// 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),
)
.when_some(initial_text, |editor, text| editor.with_initial_text(text))
.when_some(
metadata.and_then(|metadata| metadata.placeholder),
@@ -4789,6 +4826,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)
})
.disabled(disabled)
.on_click({
move |state, window, cx| {
@@ -4822,6 +4862,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)
})
.on_change({
move |value, window, cx| {
let value = *value;
@@ -4878,6 +4921,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)
})
.disabled(disabled)
.tab_index(0)
.title_case(should_do_titlecase)
@@ -4886,6 +4932,7 @@ where
fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button {
Button::new(id, label)
.aria_role(Role::ComboBox)
.tab_index(0_isize)
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
@@ -4896,6 +4943,24 @@ fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button
)
}
/// Wires the Expand/Collapse accessibility actions on a picker trigger button to
/// the popover handle, so assistive technology can open and close the picker
/// (used by UIA on Windows and AX on macOS; Linux/AT-SPI uses the click action).
fn wire_picker_trigger_a11y<M: gpui::ManagedView>(
button: Button,
handle: ui::PopoverMenuHandle<M>,
) -> Button {
let show_handle = handle.clone();
let hide_handle = handle;
button
.on_a11y_action(gpui::accesskit::Action::Expand, move |_, window, cx| {
show_handle.show(window, cx);
})
.on_a11y_action(gpui::accesskit::Action::Collapse, move |_, _window, cx| {
hide_handle.hide(cx);
})
}
fn render_font_picker(
field: SettingField<settings::FontFamilyName>,
file: SettingsUiFile,
@@ -4909,10 +4974,17 @@ fn render_font_picker(
.cloned()
.map_or_else(|| SharedString::default(), |value| value.into_gpui());
let handle = ui::PopoverMenuHandle::default();
PopoverMenu::new("font-picker")
.trigger(render_picker_trigger_button(
"font_family_picker_trigger".into(),
current_value.clone(),
.trigger(wire_picker_trigger_a11y(
render_picker_trigger_button(
"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()))
}),
handle.clone(),
))
.menu(move |window, cx| {
let file = file.clone();
@@ -4943,7 +5015,7 @@ fn render_font_picker(
x: px(0.0),
y: px(2.0),
})
.with_handle(ui::PopoverMenuHandle::default())
.with_handle(handle)
.into_any_element()
}
@@ -4960,10 +5032,14 @@ fn render_theme_picker(
.map(|theme_name| theme_name.0.into())
.unwrap_or_else(|| cx.theme().name.clone());
let handle = ui::PopoverMenuHandle::default();
PopoverMenu::new("theme-picker")
.trigger(render_picker_trigger_button(
"theme_picker_trigger".into(),
current_value.clone(),
.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()))
}),
handle.clone(),
))
.menu(move |window, cx| {
Some(cx.new(|cx| {
@@ -4997,7 +5073,7 @@ fn render_theme_picker(
x: px(0.0),
y: px(2.0),
})
.with_handle(ui::PopoverMenuHandle::default())
.with_handle(handle)
.into_any_element()
}
@@ -5014,10 +5090,14 @@ fn render_icon_theme_picker(
.map(|theme_name| theme_name.0.into())
.unwrap_or_else(|| cx.theme().name.clone());
let handle = ui::PopoverMenuHandle::default();
PopoverMenu::new("icon-theme-picker")
.trigger(render_picker_trigger_button(
"icon_theme_picker_trigger".into(),
current_value.clone(),
.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()))
}),
handle.clone(),
))
.menu(move |window, cx| {
Some(cx.new(|cx| {
@@ -5051,7 +5131,7 @@ fn render_icon_theme_picker(
x: px(0.0),
y: px(2.0),
})
.with_handle(ui::PopoverMenuHandle::default())
.with_handle(handle)
.into_any_element()
}
+2 -1
View File
@@ -167,7 +167,8 @@ impl ApplicationMenu {
ui::IconName::Menu,
)
.style(ButtonStyle::Subtle)
.icon_size(IconSize::Small),
.icon_size(IconSize::Small)
.aria_label("Application menu"),
Tooltip::text("Open Application Menu"),
)
.with_handle(handle),
+2 -1
View File
@@ -1251,7 +1251,7 @@ impl TitleBar {
}
});
ButtonLike::new("user-menu").child(
ButtonLike::new("user-menu").aria_label("User menu").child(
h_flex()
.when_some(business_organization, |this, organization| {
this.gap_2()
@@ -1261,6 +1261,7 @@ impl TitleBar {
)
} else {
ButtonLike::new("user-menu")
.aria_label("User menu")
.child(Icon::new(IconName::ChevronDown).size(IconSize::Small))
};
+37 -1
View File
@@ -123,6 +123,38 @@ impl Button {
self
}
/// Sets the label announced by assistive technology.
/// Defaults to the button's visible label.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.base = self.base.aria_label(label);
self
}
/// Overrides the role reported to assistive technology.
/// Defaults to [`gpui::Role::Button`].
pub fn aria_role(mut self, role: gpui::Role) -> Self {
self.base = self.base.aria_role(role);
self
}
/// Sets the expanded state reported to assistive technology, for buttons
/// that control a popup (e.g. dropdown or disclosure triggers).
pub fn aria_expanded(mut self, expanded: bool) -> Self {
self.base = self.base.aria_expanded(expanded);
self
}
/// Registers a handler for an accessibility action (e.g.
/// [`gpui::accesskit::Action::Expand`]) dispatched by assistive technology.
pub fn on_a11y_action(
mut self,
action: gpui::accesskit::Action,
listener: impl FnMut(Option<&gpui::accesskit::ActionData>, &mut Window, &mut App) + 'static,
) -> Self {
self.base = self.base.on_a11y_action(action, listener);
self
}
/// Defines the size of the button's label.
pub fn label_size(mut self, label_size: impl Into<Option<LabelSize>>) -> Self {
self.label_size = label_size.into();
@@ -369,7 +401,7 @@ impl ButtonCommon for Button {
impl RenderOnce for Button {
#[allow(refining_impl_trait)]
fn render(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;
@@ -378,6 +410,10 @@ impl RenderOnce for Button {
.filter(|_| is_selected)
.unwrap_or(self.label);
if self.base.aria_label.is_none() {
self.base.aria_label = Some(label.clone());
}
let label_color = if is_disabled {
Color::Disabled
} else if is_selected {
+65 -1
View File
@@ -1,7 +1,7 @@
use documented::Documented;
use gpui::{
AnyElement, AnyView, ClickEvent, CursorStyle, DefiniteLength, FocusHandle, Hsla, MouseButton,
MouseClickEvent, MouseDownEvent, MouseUpEvent, Rems, StyleRefinement, relative,
MouseClickEvent, MouseDownEvent, MouseUpEvent, Rems, Role, StyleRefinement, Toggled, relative,
transparent_black,
};
use smallvec::SmallVec;
@@ -492,11 +492,19 @@ pub struct ButtonLike {
tab_index: Option<isize>,
size: ButtonSize,
rounding: Option<ButtonLikeRounding>,
pub(super) aria_label: Option<SharedString>,
aria_role: Option<Role>,
aria_expanded: Option<bool>,
toggled: Option<bool>,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
hoverable_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
cursor_style: CursorStyle,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_right_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
a11y_actions: Vec<(
gpui::accesskit::Action,
Box<dyn FnMut(Option<&gpui::accesskit::ActionData>, &mut Window, &mut App) + 'static>,
)>,
children: SmallVec<[AnyElement; 2]>,
focus_handle: Option<FocusHandle>,
}
@@ -514,12 +522,17 @@ impl ButtonLike {
height: None,
size: ButtonSize::Default,
rounding: Some(ButtonLikeRounding::ALL),
aria_label: None,
aria_role: None,
aria_expanded: None,
toggled: None,
tooltip: None,
hoverable_tooltip: None,
children: SmallVec::new(),
cursor_style: CursorStyle::PointingHand,
on_click: None,
on_right_click: None,
a11y_actions: Vec::new(),
layer: None,
tab_index: None,
focus_handle: None,
@@ -568,6 +581,38 @@ impl ButtonLike {
self.hoverable_tooltip = Some(Box::new(tooltip));
self
}
/// Sets the label announced by assistive technology for this button.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.aria_label = Some(label.into());
self
}
/// Overrides the role reported to assistive technology.
/// Defaults to [`Role::Button`].
pub fn aria_role(mut self, role: Role) -> Self {
self.aria_role = Some(role);
self
}
/// Sets the expanded state reported to assistive technology, for buttons
/// that control a popup (e.g. dropdown or disclosure triggers).
pub fn aria_expanded(mut self, expanded: bool) -> Self {
self.aria_expanded = Some(expanded);
self
}
/// Registers a handler for an accessibility action (e.g.
/// [`accesskit::Action::Expand`]) dispatched by assistive technology to
/// this button. Also advertises the action to assistive technology.
pub fn on_a11y_action(
mut self,
action: gpui::accesskit::Action,
listener: impl FnMut(Option<&gpui::accesskit::ActionData>, &mut Window, &mut App) + 'static,
) -> Self {
self.a11y_actions.push((action, Box::new(listener)));
self
}
}
impl Disableable for ButtonLike {
@@ -580,6 +625,7 @@ impl Disableable for ButtonLike {
impl Toggleable for ButtonLike {
fn toggle_state(mut self, selected: bool) -> Self {
self.selected = selected;
self.toggled = Some(selected);
self
}
}
@@ -679,6 +725,18 @@ impl RenderOnce for ButtonLike {
self.base
.h_flex()
.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_expanded, |this, expanded| {
this.aria_expanded(expanded)
})
.when_some(self.toggled, |this, toggled| {
this.aria_toggled(if toggled {
Toggled::True
} else {
Toggled::False
})
})
.when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index))
.when_some(self.focus_handle, |this, focus_handle| {
this.track_focus(&focus_handle)
@@ -779,6 +837,12 @@ impl RenderOnce for ButtonLike {
.when_some(self.hoverable_tooltip, |this, tooltip| {
this.hoverable_tooltip(move |window, cx| tooltip(window, cx))
})
.map(|mut this| {
for (action, listener) in self.a11y_actions {
this = this.on_a11y_action(action, listener);
}
this
})
.children(self.children)
}
}
@@ -53,6 +53,33 @@ impl IconButton {
self
}
/// Sets the label announced by assistive technology.
///
/// Icon buttons have no visible text, so they should always set this for
/// screen reader users. Often this matches the tooltip text.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.base = self.base.aria_label(label);
self
}
/// Sets the expanded state reported to assistive technology, for buttons
/// that control a popup (e.g. dropdown or disclosure triggers).
pub fn aria_expanded(mut self, expanded: bool) -> Self {
self.base = self.base.aria_expanded(expanded);
self
}
/// Registers a handler for an accessibility action (e.g.
/// [`gpui::accesskit::Action::Expand`]) dispatched by assistive technology.
pub fn on_a11y_action(
mut self,
action: gpui::accesskit::Action,
listener: impl FnMut(Option<&gpui::accesskit::ActionData>, &mut Window, &mut App) + 'static,
) -> Self {
self.base = self.base.on_a11y_action(action, listener);
self
}
pub fn icon_size(mut self, icon_size: IconSize) -> Self {
self.icon_size = icon_size;
self
+35 -6
View File
@@ -4,8 +4,8 @@ use crate::{
};
use gpui::{
Action, Anchor, AnyElement, App, Bounds, DismissEvent, Entity, EventEmitter, FocusHandle,
Focusable, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Size,
Subscription, TaskExt, anchored, canvas, prelude::*, px,
Focusable, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Role,
Size, Subscription, TaskExt, anchored, canvas, prelude::*, px,
};
use menu::{SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious};
use std::{
@@ -1391,6 +1391,11 @@ impl ContextMenu {
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
// The menu keeps real focus on its container, so for assistive
// technology to track the selected item we report it as the active
// descendant. GPUI only honors this while the menu actually holds
// focus, so we mark the selected item unconditionally here.
let is_active_descendant = |selectable: bool| selectable && Some(ix) == self.selected_index;
match item {
ContextMenuItem::Separator => ListSeparator.into_any_element(),
ContextMenuItem::Header(header) => ListSubHeader::new(header.clone())
@@ -1420,9 +1425,9 @@ impl ContextMenu {
.disabled(true)
.child(Label::new(label.clone()))
.into_any_element(),
ContextMenuItem::Entry(entry) => {
self.render_menu_entry(ix, entry, cx).into_any_element()
}
ContextMenuItem::Entry(entry) => self
.render_menu_entry(ix, entry, is_active_descendant(true), cx)
.into_any_element(),
ContextMenuItem::CustomEntry {
entry_render,
handler,
@@ -1469,6 +1474,10 @@ impl ContextMenu {
.child(
ListItem::new(ix)
.inset(true)
.when(selectable, |item| item.aria_role(Role::MenuItem))
.when(is_active_descendant(selectable), |item| {
item.aria_active_descendant()
})
.toggle_state(Some(ix) == self.selected_index)
.selectable(selectable)
.when(selectable, |item| {
@@ -1500,7 +1509,14 @@ impl ContextMenu {
icon_color,
..
} => self
.render_submenu_item_trigger(ix, label.clone(), *icon, *icon_color, cx)
.render_submenu_item_trigger(
ix,
label.clone(),
*icon,
*icon_color,
is_active_descendant(true),
cx,
)
.into_any_element(),
}
}
@@ -1511,6 +1527,7 @@ impl ContextMenu {
label: SharedString,
icon: Option<IconName>,
icon_color: Option<Color>,
is_active_descendant: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
let toggle_state = Some(ix) == self.selected_index
@@ -1544,6 +1561,9 @@ impl ContextMenu {
.child(
ListItem::new(ix)
.inset(true)
.aria_role(Role::MenuItem)
.when(is_active_descendant, |item| item.aria_active_descendant())
.aria_label(label.clone())
.toggle_state(toggle_state)
.child(
canvas(
@@ -1722,6 +1742,7 @@ impl ContextMenu {
&self,
ix: usize,
entry: &ContextMenuEntry,
is_active_descendant: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
let ContextMenuEntry {
@@ -1862,6 +1883,13 @@ impl ContextMenu {
.group_name("label_container")
.inset(true)
.disabled(*disabled)
.aria_role(if toggle.is_some() {
Role::MenuItemCheckBox
} else {
Role::MenuItem
})
.when(is_active_descendant, |item| item.aria_active_descendant())
.aria_label(label.clone())
.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| {
@@ -2155,6 +2183,7 @@ impl Render for ContextMenu {
.child(
v_flex()
.id("context-menu")
.role(Role::Menu)
.max_h(vh(0.75, window))
.flex_shrink_0()
.child(menu_bounds_measure)
+2
View File
@@ -100,6 +100,8 @@ impl RenderOnce for Disclosure {
)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.aria_label(if self.is_open { "Collapse" } else { "Expand" })
.aria_expanded(self.is_open)
.disabled(self.disabled)
.toggle_state(self.selected)
.when_some(self.visible_on_hover.clone(), |this, group_name| {
+48 -4
View File
@@ -1,4 +1,4 @@
use gpui::{Anchor, AnyView, Entity, Pixels, Point};
use gpui::{Anchor, AnyView, Entity, Pixels, Point, Role};
use crate::{ButtonLike, ContextMenu, PopoverMenu, prelude::*};
@@ -34,6 +34,7 @@ pub struct DropdownMenu {
offset: Option<Point<Pixels>>,
tab_index: Option<isize>,
chevron: bool,
aria_label: Option<SharedString>,
}
impl DropdownMenu {
@@ -57,6 +58,7 @@ impl DropdownMenu {
offset: None,
tab_index: None,
chevron: true,
aria_label: None,
}
}
@@ -80,6 +82,7 @@ impl DropdownMenu {
offset: None,
tab_index: None,
chevron: true,
aria_label: None,
}
}
@@ -137,6 +140,13 @@ impl DropdownMenu {
self.chevron = false;
self
}
/// Sets the label announced by assistive technology.
/// Defaults to the trigger's visible label (typically the current value).
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.aria_label = Some(label.into());
self
}
}
impl Disableable for DropdownMenu {
@@ -157,11 +167,42 @@ impl RenderOnce for DropdownMenu {
let full_width = self.full_width;
let trigger_size = self.trigger_size;
// Ensure a handle exists so assistive technology can open/close the menu
// via the Expand/Collapse accessibility actions (used by UIA on Windows
// and AX on macOS; on Linux/AT-SPI the click action is used instead).
let handle = self.handle.unwrap_or_default();
let expanded = handle.is_deployed();
let a11y_actions = |button: Button| {
let show_handle = handle.clone();
let hide_handle = handle.clone();
button
.on_a11y_action(gpui::accesskit::Action::Expand, move |_, window, cx| {
show_handle.show(window, cx);
})
.on_a11y_action(gpui::accesskit::Action::Collapse, move |_, _window, cx| {
hide_handle.hide(cx);
})
};
let a11y_actions_element = |button: ButtonLike| {
let show_handle = handle.clone();
let hide_handle = handle.clone();
button
.on_a11y_action(gpui::accesskit::Action::Expand, move |_, window, cx| {
show_handle.show(window, cx);
})
.on_a11y_action(gpui::accesskit::Action::Collapse, move |_, _window, cx| {
hide_handle.hide(cx);
})
};
let (text_button, element_button) = match self.label {
LabelKind::Text(text) => (
Some(
Button::new(self.id.clone(), text)
a11y_actions(Button::new(self.id.clone(), text))
.aria_role(Role::ComboBox)
.when_some(self.aria_label, |this, label| this.aria_label(label))
.aria_expanded(expanded)
.style(button_style)
.when_some(self.trigger_icon.filter(|_| self.chevron), |this, icon| {
this.end_icon(
@@ -178,7 +219,10 @@ impl RenderOnce for DropdownMenu {
LabelKind::Element(element) => (
None,
Some(
ButtonLike::new(self.id.clone())
a11y_actions_element(ButtonLike::new(self.id.clone()))
.aria_role(Role::ComboBox)
.when_some(self.aria_label, |this, label| this.aria_label(label))
.aria_expanded(expanded)
.child(element)
.style(button_style)
.when(self.chevron, |this| {
@@ -198,6 +242,7 @@ impl RenderOnce for DropdownMenu {
let mut popover = PopoverMenu::new((self.id.clone(), "popover"))
.full_width(self.full_width)
.with_handle(handle)
.menu(move |_window, _cx| Some(self.menu.clone()));
popover = match (text_button, element_button, self.trigger_tooltip) {
@@ -218,7 +263,6 @@ impl RenderOnce for DropdownMenu {
None => Anchor::BottomRight,
})
.when_some(self.offset, |this, offset| this.offset(offset))
.when_some(self.handle, |this, handle| this.with_handle(handle))
}
}
+54 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use component::{Component, ComponentScope, example_group_with_title, single_example};
use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px};
use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, Role, px};
use smallvec::SmallVec;
use crate::{Disclosure, prelude::*};
@@ -53,6 +53,9 @@ pub struct ListItem {
focused: Option<bool>,
docked_right: bool,
height: Option<DefiniteLength>,
aria_role: Option<Role>,
aria_label: Option<SharedString>,
aria_active_descendant: bool,
}
impl ListItem {
@@ -84,9 +87,40 @@ impl ListItem {
focused: None,
docked_right: false,
height: None,
aria_role: None,
aria_label: None,
aria_active_descendant: false,
}
}
/// Sets the accessible role reported to assistive technology (e.g.
/// [`Role::MenuItem`] when this item is part of a menu). When unset, the
/// item is not reported as a distinct node, matching list defaults.
pub fn aria_role(mut self, role: Role) -> Self {
self.aria_role = Some(role);
self
}
/// Sets the label announced by assistive technology. List items render
/// arbitrary children, so this should be set when a meaningful textual
/// label exists.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.aria_label = Some(label.into());
self
}
/// Reports this item as the accessibility focus, overriding the element
/// that holds real keyboard focus. Requires [`Self::aria_role`] to be set.
///
/// Use this on the selected item of a composite widget (e.g. a menu) that
/// keeps keyboard focus on its container. It is honored only while the
/// container actually holds focus, so it is safe to set unconditionally on
/// the selected item. See [`StatefulInteractiveElement::aria_active_descendant`].
pub fn aria_active_descendant(mut self) -> Self {
self.aria_active_descendant = true;
self
}
pub fn group_name(mut self, group_name: impl Into<SharedString>) -> Self {
self.group_name = Some(group_name.into());
self
@@ -270,6 +304,25 @@ impl RenderOnce for ListItem {
.child(
h_flex()
.id("inner_list_item")
// The accessible role/label live here, alongside the click
// handler, so assistive technology reports one actionable
// node (e.g. a menu item) rather than an inert container.
.when_some(self.aria_role, |this, role| this.role(role))
.when(
self.aria_role.is_some() && self.aria_active_descendant,
|this| this.aria_active_descendant(),
)
.when_some(self.aria_label, |this, label| this.aria_label(label))
.when(self.aria_role.is_some(), |this| {
this.aria_selected(self.selected)
.when_some(self.toggle, |this, toggled| {
this.aria_toggled(if toggled {
gpui::Toggled::True
} else {
gpui::Toggled::False
})
})
})
.group("list_item")
.w_full()
.relative()
+18 -1
View File
@@ -1,6 +1,6 @@
use gpui::{
AnyElement, AnyView, ClickEvent, ElementId, Hsla, IntoElement, KeybindingKeystroke, Keystroke,
Styled, Window, div, hsla, prelude::*,
Role, Styled, Toggled, Window, div, hsla, prelude::*,
};
use std::{rc::Rc, sync::Arc};
@@ -347,6 +347,7 @@ pub struct Switch {
key_binding: Option<KeyBinding>,
color: SwitchColor,
tab_index: Option<isize>,
aria_label: Option<SharedString>,
}
impl Switch {
@@ -364,6 +365,7 @@ impl Switch {
key_binding: None,
color: SwitchColor::default(),
tab_index: None,
aria_label: None,
}
}
@@ -422,6 +424,13 @@ impl Switch {
self.tab_index = Some(tab_index.into());
self
}
/// Sets the label announced by assistive technology.
/// Defaults to the switch's visible label, if any.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.aria_label = Some(label.into());
self
}
}
impl RenderOnce for Switch {
@@ -447,9 +456,17 @@ 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 switch = div()
.id((self.id.clone(), "switch"))
.role(Role::Switch)
.when_some(aria_label, |this, label| this.aria_label(label))
.aria_toggled(match self.toggle_state {
ToggleState::Selected => Toggled::True,
ToggleState::Indeterminate => Toggled::Mixed,
ToggleState::Unselected => Toggled::False,
})
.p(px(1.0))
.border_2()
.border_color(cx.theme().colors().border_transparent)
+15 -1
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent};
use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Role};
use crate::{Disclosure, prelude::*};
@@ -151,6 +151,11 @@ impl RenderOnce for TreeViewItem {
.bg(cx.theme().colors().border.opacity(0.5)),
);
let aria_label = self.label.clone();
let root_item = self.root_item;
let expanded = self.expanded;
let selected = self.selected;
h_flex()
.id(self.id)
.when_some(self.group_name, |this, group| this.group(group))
@@ -158,6 +163,15 @@ impl RenderOnce for TreeViewItem {
.child(
h_flex()
.id("inner_tree_view_item")
// The accessible role and state live on this element
// because it also carries the click handler and focus, so
// assistive technology reports a single actionable tree
// item rather than an inert container.
.role(Role::TreeItem)
.aria_label(aria_label)
.aria_selected(selected)
.aria_level(if root_item { 1 } else { 2 })
.when(root_item, |this| this.aria_expanded(expanded))
.cursor_pointer()
.size_full()
.h(item_size)