GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(a11y): a reader can press and set widgets, not only focus them
AT-SPI reaches two actions through AccessKit: `click`, and SetCurrentValue on
the Value interface (Action::SetValue with a number). Click focuses the widget
and presses Space through the app's own handle_key_input
(Driver::press_named_key), the key that activates a plate; Increment /
Decrement press the arrows, for the adapters that carry them. a11y::key_for is
the one table, so a node offers exactly the actions the runner performs.
SetValue sets the value on the widget (Input::a11y_set_value, Slider and
Spinbox), clamped and marked changed as a typed value is, without moving
focus; not by keys, since a key a widget does not take falls through to the
app. Slider and spin-button nodes carry their range and step
(Input::a11y_range).
Spinbox: Up / Down step a focused box, and a focused box takes keys again
after Enter, where it ignored every key until refocused.
Verified on cce-data-editor in a shadow through Atspi: a check box clicked on
and off, spin buttons set to 7 and 3 with their ranges read back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/rfc-accessibility-locale.md | 28 +++++++++-
src/a11y.rs | 69 +++++++++++++++++++++++--
src/backend/a11y_unix.rs | 87 +++++++++++++++++++++----------
src/backend/driver.rs | 27 ++++++++++
src/backend/window_runner.rs | 24 ++++++---
src/widget/input/slider.rs | 9 ++++
src/widget/input/spinbox.rs | 109 ++++++++++++++++++++++++++++++++++++++-
src/widget/mod.rs | 10 ++++
src/widget/model.rs | 23 +++++++++
src/widget/owned.rs | 2 +
10 files changed, 348 insertions(+), 40 deletions(-)
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index d67d609..9c119e7 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -138,8 +138,32 @@ republished. A tree of 21 nodes builds in 50–100 µs in a debug build.
What it showed is the apps' to fix, not the adapter's: most of the data editor's controls
have no label (an unlabelled field is an empty name to a reader), and its inactive editors
-are registered and visible though not drawn. Next: a widget `Click` (activate), the macOS
-adapter onto the AppKit view, and listening with Orca.
+are registered and visible though not drawn.
+
+**Widget actions (2026-10-08).** A reader can now press and set widgets, not only focus
+them. What AT-SPI reaches through AccessKit is narrower than AccessKit's action list: the
+Action interface carries `click` alone, and a value is adjusted through the Value
+interface's `SetCurrentValue` (`Action::SetValue` with a number); Increment / Decrement
+exist for the macOS and Windows adapters. So:
+- **Click** focuses the widget and presses Space through the app's own
+ `handle_key_input` (`Driver::press_named_key`), what activates a plate from the keyboard,
+ so the app hears of it as it hears of a key. **Increment / Decrement** are the arrows
+ (Right / Left on a slider or range, Up / Down on a spin button), ready for the other
+ adapters. `a11y::key_for` is the one table: a node offers exactly the actions it answers.
+- **SetValue** sets the value on the widget (`Input::a11y_set_value`, Slider and Spinbox),
+ clamped and marked changed as a typed value is, without moving focus. Not by keys: a key
+ a widget does not take falls through to the app, where Backspace or Escape may mean
+ something else. An app that drains `take_change` in `tick` sees it this turn; one that
+ drains only in its input handlers, at the next input.
+- A slider's or spin button's node carries its range and step (`Input::a11y_range`), what a
+ reader reads a percentage and a step from.
+- A Spinbox now steps on Up / Down, and a focused box takes keys again after Enter (it
+ ignored every key until refocused).
+
+Verified on cce-data-editor in a shadow: a check box clicked on and off (CHECKED read
+back), spin buttons set to 7 and 3 with their ranges read. Next: the macOS adapter onto
+the AppKit view, a text field's `SetValue` (AT-SPI's editable text), and listening with
+Orca.
- **Wayland / Linux:** AT-SPI over D-Bus. AccessKit's Unix adapter is the likely carrier
(evaluate it first; the alternative is a small AT-SPI server of our own). The compositor
diff --git a/src/a11y.rs b/src/a11y.rs
index cb1e3ca..8923ea9 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -34,7 +34,7 @@
use accesskit::{Action, Affine, Node, NodeId, Rect, Role, Toggled, TreeId, TreeInfo, TreeUpdate};
use crate::context::UiContext;
-use crate::widget::{FocusRole, WidgetHost, WidgetId};
+use crate::widget::{FocusRole, NamedKey, WidgetHost, WidgetId};
/// The window's node, the root every widget hangs from.
pub const WINDOW: NodeId = NodeId(0);
@@ -161,8 +161,18 @@ pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
if focus != FocusRole::None {
node.add_action(Action::Focus);
}
- if focus == FocusRole::Plate {
- node.add_action(Action::Click);
+ if let Some((min, max, step)) = w.a11y_range() {
+ node.set_min_numeric_value(min);
+ node.set_max_numeric_value(max);
+ if step > 0.0 {
+ node.set_numeric_value_step(step);
+ }
+ node.add_action(Action::SetValue);
+ }
+ for action in [Action::Click, Action::Increment, Action::Decrement] {
+ if key_for(w, action).is_some() {
+ node.add_action(action);
+ }
}
if !children.is_empty() {
node.set_children(children);
@@ -170,6 +180,23 @@ pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
node
}
+/// The key a keyboard user presses on the focused widget to do `action`, which is how the
+/// Linux adapter carries it out (`backend::a11y_unix::act`): Space clicks a plate (what
+/// arms on `FocusIn`); Right / Left step a slider or a range's focused end, Up / Down a
+/// spin button. `None`: the widget does not take it. The node advertises exactly the
+/// actions this answers, so the tree never offers one the runner cannot perform.
+pub fn key_for(w: &dyn WidgetHost, action: Action) -> Option<NamedKey> {
+ let role = role_for(w.type_name(), w.focus_role(), w.a11y_role());
+ match (action, role) {
+ (Action::Click, _) if w.focus_role() == FocusRole::Plate => Some(NamedKey::Space),
+ (Action::Increment, Role::Slider) if w.type_name() != "Slider2D" => Some(NamedKey::ArrowRight),
+ (Action::Decrement, Role::Slider) if w.type_name() != "Slider2D" => Some(NamedKey::ArrowLeft),
+ (Action::Increment, Role::SpinButton) => Some(NamedKey::ArrowUp),
+ (Action::Decrement, Role::SpinButton) => Some(NamedKey::ArrowDown),
+ _ => None,
+ }
+}
+
/// Whether a widget is on screen: visible, with a size, and not parked. Apps park a widget
/// they are not showing far off the window (x or y below -9000, the toolkit's sentinel —
/// cce-data-editor's per-type value editors) instead of hiding it; a reader must not see
@@ -327,7 +354,41 @@ fn push_context_menu(nodes: &mut Vec<(NodeId, Node)>) -> Option<NodeId> {
#[cfg(test)]
mod tests {
use super::*;
- use crate::widget::{Button, Checkbox, Owned, Slider, TextBox};
+ use crate::widget::{Button, Checkbox, Owned, RangeSlider, Slider, Spinbox, TextBox};
+
+ /// A node offers the actions the Linux adapter can carry out, and each by the key a
+ /// keyboard user presses: Space on a plate, Right / Left on a slider or a range,
+ /// Up / Down on a spin button, nothing on a text box.
+ #[test]
+ fn a_node_offers_the_actions_its_keys_carry_out() {
+ let button = Button::new(0.0, 0.0, 80.0, 24.0).with_label("Save");
+ let slider = Slider::new().with_label("Zoom");
+ let range = RangeSlider::new();
+ let spin = Spinbox::new(0, 0, 10, 1);
+ let text = TextBox::new(String::new());
+ let check = Checkbox::new();
+ let offers = |w: &dyn WidgetHost| {
+ let node = widget_node(w, Vec::new());
+ [Action::Click, Action::Increment, Action::Decrement]
+ .into_iter()
+ .filter(|a| node.supports_action(*a))
+ .map(|a| (a, key_for(w, a).expect("an offered action has its key")))
+ .collect::<Vec<_>>()
+ };
+ assert_eq!(offers(&button), [(Action::Click, NamedKey::Space)]);
+ assert_eq!(offers(&check), [(Action::Click, NamedKey::Space)]);
+ assert_eq!(offers(&slider), [(Action::Increment, NamedKey::ArrowRight), (Action::Decrement, NamedKey::ArrowLeft)]);
+ assert_eq!(offers(&range), [(Action::Increment, NamedKey::ArrowRight), (Action::Decrement, NamedKey::ArrowLeft)]);
+ assert_eq!(offers(&spin), [(Action::Increment, NamedKey::ArrowUp), (Action::Decrement, NamedKey::ArrowDown)]);
+ assert_eq!(offers(&text), [], "a well is typed into, not pressed or stepped");
+
+ let spin_node = widget_node(&spin, Vec::new());
+ assert!(spin_node.supports_action(Action::SetValue), "a reader sets a spin button");
+ assert_eq!((spin_node.min_numeric_value(), spin_node.max_numeric_value()), (Some(0.0), Some(10.0)));
+ assert_eq!(spin_node.numeric_value_step(), Some(1.0));
+ assert!(!widget_node(&button, Vec::new()).supports_action(Action::SetValue));
+ assert_eq!(key_for(&text, Action::Click), None);
+ }
fn node<'a>(update: &'a TreeUpdate, id: NodeId) -> &'a Node {
&update.nodes.iter().find(|(n, _)| *n == id).expect("node in the update").1
diff --git a/src/backend/a11y_unix.rs b/src/backend/a11y_unix.rs
index bff4428..c073327 100644
--- a/src/backend/a11y_unix.rs
+++ b/src/backend/a11y_unix.rs
@@ -12,13 +12,17 @@
//! an idle window renders nothing, so the runner publishes from the event itself.
//! - **after every frame**: publish again ([`Publisher::publish`] builds nothing while no
//! screen reader is connected — `update_if_active`).
-//! - **asked** ([`Event::Action`]): [`act`] — focus a widget, or press a context-menu row.
+//! - **asked** ([`Event::Action`]): [`act`] — focus a widget, press or step one (by the key a
+//! keyboard user would press, through the app's own key handling), or press a context-menu
+//! row.
//! - **the window's keyboard focus**: [`Publisher::window_focus`].
//!
//! Without the `a11y` feature the same API compiles to a stub whose [`Publisher::start`] is
//! `None`, so the runner carries no `cfg`.
-use accesskit::{Action, ActionRequest};
+use accesskit::{Action, ActionData, ActionRequest};
+
+use crate::widget::NamedKey;
use crate::backend::app::Application;
@@ -45,41 +49,72 @@ pub fn wanted<A: Application>(app: &A) -> bool {
app.publishes_accessibility() || std::env::var("CCE_A11Y").is_ok_and(|v| v == "1")
}
-/// Carry out an assistive tool's request on `app`; true when something changed (redraw).
+/// What [`act`] did, and what is left for the runner to do.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Acted {
+ /// Nothing: the target is gone, or the action is not one it takes.
+ Nothing,
+ /// Done; redraw and republish.
+ Changed,
+ /// The target is focused; now press this key as a keyboard user would
+ /// (`Driver::press_named_key`), so the widget and the app answer it as they answer one.
+ Key(NamedKey),
+}
+
+/// Carry out an assistive tool's request on `app`.
///
/// - **Focus** on a widget's node focuses it, through the app's `UiContext` like a Tab step.
+/// - **Click** on a widget focuses it and presses Space: what activates a plate from the
+/// keyboard (`FocusRole::Plate`), so the app learns of it the way it learns of a key.
+/// - **Increment / Decrement** focus a slider or spin button and press the key that steps
+/// it: Right / Left on a slider (a `RangeSlider` uses Up / Down to change ends), Up / Down
+/// on a spin button.
+/// - **SetValue** with a number (AT-SPI's `SetCurrentValue`, how a reader adjusts a slider
+/// or spin button on Linux, where AccessKit offers no Increment) sets it on the widget
+/// (`WidgetHost::a11y_set_value`), marked changed for the app's `take_change`. An app
+/// that drains changes in `tick` sees it this turn; one that drains them only in its
+/// input handlers sees it at the next input.
/// - **Click** on an open context menu's row presses it where it is drawn, so the menu runs
/// the row's action exactly as a pointer would.
///
-/// Anything else is not supported yet and does nothing, as AccessKit requires of an action
-/// the app cannot perform (a click on a widget is the next step).
-pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> bool {
+/// Anything else does nothing, as AccessKit requires of an action the app cannot perform.
+/// A key reaches the widget only through the app's `handle_key_input`, as every key does —
+/// an app that does not route keys to its focused widget answers a reader as it answers a
+/// keyboard.
+pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
use crate::widget::context_menu as cm;
if debug() {
eprintln!("[a11y] action {:?} on {:?}", request.action, request.target_node);
}
- match request.action {
- Action::Focus => {
- let (Some(id), Some(ctx)) = (crate::a11y::widget_of(request.target_node), app.ui_context_mut()) else {
- return false;
- };
- if ctx.tree.get_ptr(id).is_none() {
- return false;
- }
- ctx.set_focused_id(id);
- true
+ if let Some(row) = crate::a11y::menu_row_of(request.target_node) {
+ if request.action != Action::Click || !cm::is_visible() || row >= cm::options().len() {
+ return Acted::Nothing;
}
- Action::Click => {
- let Some(row) = crate::a11y::menu_row_of(request.target_node) else { return false };
- if !cm::is_visible() || row >= cm::options().len() {
- return false;
- }
- let (x, y) = (cm::x() + cm::w() * 0.5, cm::row_y(row) + cm::ROW_H * 0.5);
- cm::mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, x, y, app.ui_context_mut());
- true
- }
- _ => false,
+ let (x, y) = (cm::x() + cm::w() * 0.5, cm::row_y(row) + cm::ROW_H * 0.5);
+ cm::mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, x, y, app.ui_context_mut());
+ return Acted::Changed;
+ }
+ let Some(id) = crate::a11y::widget_of(request.target_node) else { return Acted::Nothing };
+ let Some(ctx) = app.ui_context_mut() else { return Acted::Nothing };
+ if request.action == Action::SetValue {
+ // Set where it is, focus untouched: a reader adjusting a value has not moved.
+ let Some(ActionData::NumericValue(value)) = request.data else { return Acted::Nothing };
+ let changed = ctx.get_widget_mut(id).is_some_and(|w| w.a11y_set_value(value));
+ return if changed { Acted::Changed } else { Acted::Nothing };
+ }
+ let Some(w) = ctx.get_widget(id) else { return Acted::Nothing };
+ let key = match request.action {
+ Action::Focus => None,
+ action => match crate::a11y::key_for(w, action) {
+ Some(key) => Some(key),
+ None => return Acted::Nothing,
+ },
+ };
+ if ctx.focused_widget != Some(id) {
+ ctx.set_focused_id(id);
+ app.focus_stepped();
}
+ key.map_or(Acted::Changed, Acted::Key)
}
#[cfg(feature = "a11y")]
diff --git a/src/backend/driver.rs b/src/backend/driver.rs
index fe01467..1871c46 100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@ -694,6 +694,33 @@ impl Driver {
*t.redraw = true;
}
+ /// Press and release a named key on the app's behalf — an assistive tool's Click or
+ /// Increment (`backend::a11y_unix::act`), after it has focused the widget. Through the
+ /// app's own `handle_key_input` like any key, but past the chords (a reader's Space is
+ /// not Tab), with no modifiers and no text, and never repeated.
+ pub fn press_named_key<A: Application>(&mut self, t: Turn<'_, A>, key: NamedKey) {
+ self.note_input();
+ let mut rebuild = false;
+ for state in [ElementState::Pressed, ElementState::Released] {
+ let event = KeyEvent {
+ state,
+ logical_key: Key::Named(key),
+ text: None,
+ repeat: false,
+ ctrl: false,
+ shift: false,
+ alt: false,
+ };
+ let msg = t.app.handle_key_input(&event, &mut rebuild);
+ if let Some(msg) = msg {
+ let mut update_rebuild = false;
+ t.app.update(msg, &mut update_rebuild, t.exit);
+ rebuild |= update_rebuild;
+ }
+ }
+ *t.redraw = true;
+ }
+
/// The input method's composition changed (`None`: it ended without a
/// commit, or the commit follows). The editing widget shows it from the
/// next frame.
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 693787c..c16a257 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1952,8 +1952,22 @@ pub fn run<A: Application>() {
event_loop
.handle()
.insert_source(a11y_rx, |event, _metadata, app_state: &mut EngineState<A>| {
- use crate::backend::a11y_unix::{act, Event};
+ use crate::backend::a11y_unix::{act, Acted, Event};
let calloop::channel::Event::Msg(event) = event else { return };
+ if app_state.inner.is_none() {
+ return;
+ }
+ if let Event::Action(request) = &event {
+ match act(app_state.inner.as_mut().unwrap(), request) {
+ Acted::Nothing => return,
+ Acted::Changed => app_state.redraw = true,
+ Acted::Key(key) => {
+ app_state.redraw = true;
+ let (driver, t) = app_state.turn();
+ driver.press_named_key(t, key);
+ }
+ }
+ }
let (Some(publisher), Some(app)) = (app_state.a11y.as_mut(), app_state.inner.as_mut()) else { return };
match event {
// Published from here, not at the next frame: an idle window renders none,
@@ -1965,12 +1979,8 @@ pub fn run<A: Application>() {
publisher.publish(app, crate::scale::scale_factor() as f64)
}
Event::Deactivated => {}
- Event::Action(request) => {
- if act(app, &request) {
- app_state.redraw = true;
- publisher.publish(app, crate::scale::scale_factor() as f64);
- }
- }
+ // Carried out above; the tree it left is published now, as on arrival.
+ Event::Action(_) => publisher.publish(app, crate::scale::scale_factor() as f64),
}
})
.unwrap();
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index e2f016b..7ed9f06 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -668,6 +668,15 @@ impl Input for Slider {
Some(self.scaled_string())
}
+ fn a11y_range(&self) -> Option<(f64, f64, f64)> {
+ let (lo, hi) = (self.min.min(self.max) as f64, self.min.max(self.max) as f64);
+ Some((lo, hi, (self.notch_step() * (self.max - self.min).abs()) as f64))
+ }
+
+ fn a11y_set_value(&mut self, value: f64) -> bool {
+ self.set_value_string(&value.to_string())
+ }
+
fn set_value_string(&mut self, val: &str) -> bool {
if let Ok(new_val) = val.trim().parse::<f32>() {
let range = self.max - self.min;
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index dea9da9..817a4fe 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -31,6 +31,9 @@ pub struct Spinbox {
/// Wheel notches carried between events: a trackpad's fractional notches
/// add up to whole steps instead of being dropped.
wheel_accum: f32,
+ /// Whether it has the keyboard. Enter ends editing but not focus, and a focused box that
+ /// is not editing goes back into it on the keys that edit or step it.
+ focused: bool,
/// Char-index → x offsets of the value text, recorded by [`Paint::prepare_text`]
/// from the same shaped buffer the renderer draws (`ctx.text`, size 14, default
/// family). The caret and click→index math read these; the `8.4` px/char guess
@@ -96,6 +99,7 @@ impl Spinbox {
just_changed: false,
label: None,
wheel_accum: 0.0,
+ focused: false,
glyph_offsets: Vec::new(),
})
}
@@ -577,9 +581,24 @@ impl Input for Spinbox {
true
}
Event::KeyInput(key_event) => {
- if !self.editing || key_event.state != ElementState::Pressed {
+ if key_event.state != ElementState::Pressed {
return false;
}
+ if !self.editing {
+ // Focused but done editing (Enter committed): the keys that edit or step
+ // the box take it back into editing; Enter just reopens it.
+ let reopens = match &key_event.logical_key {
+ Key::Named(NamedKey::Enter | NamedKey::ArrowUp | NamedKey::ArrowDown | NamedKey::Backspace) => true,
+ _ => key_event.text.as_deref().is_some_and(|t| t.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '.') && !t.is_empty()),
+ };
+ if !self.focused || !reopens {
+ return false;
+ }
+ self.begin_edit(true);
+ if key_event.logical_key == Key::Named(NamedKey::Enter) {
+ return true;
+ }
+ }
let mut state = TextEditorState {
buffer: self.edit_buffer.clone(),
cursor_idx: self.cursor_idx,
@@ -592,6 +611,12 @@ impl Input for Spinbox {
Key::Named(NamedKey::Delete) => handled = state.delete_forwards(),
Key::Named(NamedKey::ArrowLeft) => handled = state.move_cursor_left(false),
Key::Named(NamedKey::ArrowRight) => handled = state.move_cursor_right(false),
+ // Up / Down step the value, as the -/+ run does (and an assistive
+ // tool's Increment / Decrement, which presses them).
+ Key::Named(NamedKey::ArrowUp) | Key::Named(NamedKey::ArrowDown) => {
+ self.step_by(if key_event.logical_key == Key::Named(NamedKey::ArrowUp) { 1 } else { -1 });
+ return true;
+ }
Key::Named(NamedKey::Enter) => {
let text = state.buffer.clone();
self.parse_into_value(&text);
@@ -633,10 +658,12 @@ impl Input for Spinbox {
// Focus gained programmatically enters edit mode (legacy `focus()` override);
// focus loss commits (legacy `unfocus`).
Event::FocusIn => {
+ self.focused = true;
self.begin_edit(true);
false
}
Event::FocusOut => {
+ self.focused = false;
if self.editing {
self.editing = false;
let text = self.edit_buffer.clone();
@@ -660,6 +687,26 @@ impl Input for Spinbox {
Some(self.formatted_value())
}
+ fn a11y_range(&self) -> Option<(f64, f64, f64)> {
+ let unit = 10f64.powi(self.decimals as i32);
+ Some((self.min as f64 / unit, self.max as f64 / unit, self.step as f64 / unit))
+ }
+
+ fn a11y_set_value(&mut self, value: f64) -> bool {
+ let old_val = self.value;
+ let unit = 10f64.powi(self.decimals as i32);
+ self.value = ((value * unit).round() as i32).clamp(self.min, self.max);
+ if self.value == old_val {
+ return false;
+ }
+ self.just_changed = true;
+ if self.editing {
+ self.edit_buffer = self.formatted_value();
+ self.cursor_idx = self.edit_buffer.chars().count();
+ }
+ true
+ }
+
fn set_value_string(&mut self, val: &str) -> bool {
let old_val = self.value;
self.parse_into_value(val.trim());
@@ -717,6 +764,66 @@ mod tests {
assert!(prims.iter().any(|p| matches!(p, Prim::Groove { .. })), "the -/+ seam");
}
+ /// Up / Down step a focused spinbox, as its -/+ run does: the keys a keyboard user
+ /// steps it with, and what an assistive tool's Increment / Decrement press.
+ #[test]
+ fn up_and_down_step_a_focused_spinbox() {
+ let key = |k| {
+ Event::KeyInput(crate::widget::KeyEvent { logical_key: Key::Named(k), state: ElementState::Pressed, text: None, repeat: false, ctrl: false, shift: false, alt: false })
+ };
+ let mut ctx = UiContext::new();
+ let mut sb = Spinbox::new(5, 0, 10, 2);
+ WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
+ assert!(!sb.handle_event(&key(NamedKey::ArrowUp), &mut ctx), "unfocused: not this box's key");
+ sb.handle_event(&Event::FocusIn, &mut ctx);
+ assert!(sb.handle_event(&key(NamedKey::ArrowUp), &mut ctx));
+ assert_eq!(sb.value, 7);
+ assert!(sb.take_change());
+ assert!(sb.handle_event(&key(NamedKey::ArrowDown), &mut ctx));
+ assert!(sb.handle_event(&key(NamedKey::ArrowDown), &mut ctx));
+ assert_eq!(sb.value, 3);
+ assert_eq!(sb.edit_buffer, "3", "the shown text follows while editing");
+ for _ in 0..5 {
+ sb.handle_event(&key(NamedKey::ArrowDown), &mut ctx);
+ }
+ assert_eq!(sb.value, 0, "clamped at the bottom");
+ }
+
+ /// Enter commits and leaves the box focused; the keys that edit or step it take it back
+ /// into editing, where until 2026-10-08 a focused box ignored every key after Enter.
+ #[test]
+ fn a_focused_spinbox_takes_keys_again_after_enter() {
+ let key = |k: Key, text: Option<&str>| {
+ Event::KeyInput(crate::widget::KeyEvent { logical_key: k, state: ElementState::Pressed, text: text.map(str::to_string), repeat: false, ctrl: false, shift: false, alt: false })
+ };
+ let mut ctx = UiContext::new();
+ let mut sb = Spinbox::new(5, 0, 100, 1);
+ sb.handle_event(&Event::FocusIn, &mut ctx);
+ assert!(sb.handle_event(&key(Key::Named(NamedKey::Enter), None), &mut ctx));
+ assert!(!sb.editing, "Enter commits");
+ assert!(sb.handle_event(&key(Key::Named(NamedKey::ArrowUp), None), &mut ctx), "Up steps it again");
+ assert_eq!(sb.value, 6);
+ sb.handle_event(&key(Key::Named(NamedKey::Enter), None), &mut ctx);
+ assert!(sb.handle_event(&key(Key::Character("4".into()), Some("4")), &mut ctx), "a digit reopens it");
+ assert_eq!(sb.edit_buffer, "64", "typed at the end of the shown value");
+ sb.handle_event(&Event::FocusOut, &mut ctx);
+ assert!(!sb.handle_event(&key(Key::Named(NamedKey::ArrowUp), None), &mut ctx), "unfocused: not its key");
+ }
+
+ /// What a screen reader sets (AT-SPI SetCurrentValue) lands in the box's own units,
+ /// clamped, and is reported as a change; the range is read in the same units.
+ #[test]
+ fn a_reader_sets_a_spinbox_in_its_units() {
+ let mut sb = Spinbox::new(150, 0, 500, 25).with_decimals(2);
+ assert_eq!(sb.inner().a11y_range(), Some((0.0, 5.0, 0.25)));
+ assert!(Input::a11y_set_value(sb.inner_mut(), 2.5));
+ assert_eq!(sb.value, 250);
+ assert!(sb.take_change());
+ assert!(Input::a11y_set_value(sb.inner_mut(), 9.0));
+ assert_eq!(sb.value, 500, "clamped to the top");
+ assert!(!Input::a11y_set_value(sb.inner_mut(), 5.0), "no change, none reported");
+ }
+
#[test]
fn spinbox_button_zones_step_the_value() {
let mut ctx = UiContext::new();
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 094436f..ba0ca2b 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -587,6 +587,16 @@ pub trait WidgetHost {
None
}
+ /// The `(min, max, step)` an assistive tool may set the value in (`Input::a11y_range`).
+ fn a11y_range(&self) -> Option<(f64, f64, f64)> {
+ None
+ }
+
+ /// Set the value an assistive tool asked for (`Input::a11y_set_value`).
+ fn a11y_set_value(&mut self, _value: f64) -> bool {
+ false
+ }
+
fn corner_radii(&self) -> CornerRadii {
let (r, (tl, tr, br, bl)) = self.corner_style();
CornerRadii::new(
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 590b9fc..2a1c88c 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -483,6 +483,21 @@ pub trait Input {
None
}
+ /// The range an assistive tool may set this widget's value in, as `(min, max, step)` in
+ /// the units [`value_string`](Input::value_string) reads in — a slider's or spin
+ /// button's. Default `None`: the value is not a number a reader can set.
+ fn a11y_range(&self) -> Option<(f64, f64, f64)> {
+ None
+ }
+
+ /// Set the value an assistive tool asked for (AT-SPI's `SetCurrentValue`), clamped to
+ /// [`a11y_range`](Input::a11y_range), and mark it changed as a typed value would be, so
+ /// the host's `take_change` reports it. Returns whether it changed. Default: not
+ /// settable.
+ fn a11y_set_value(&mut self, _value: f64) -> bool {
+ false
+ }
+
/// Set the widget's value from a config string. Returns whether it parsed and changed.
fn set_value_string(&mut self, _val: &str) -> bool {
false
@@ -1294,6 +1309,14 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
fn a11y_value(&self) -> Option<String> {
Input::value_string(&self.inner)
}
+
+ fn a11y_range(&self) -> Option<(f64, f64, f64)> {
+ Input::a11y_range(&self.inner)
+ }
+
+ fn a11y_set_value(&mut self, value: f64) -> bool {
+ Input::a11y_set_value(&mut self.inner, value)
+ }
fn solid_border(&self) -> Option<([f32; 4], f32)> {
Paint::solid_border(&self.inner)
}
diff --git a/src/widget/owned.rs b/src/widget/owned.rs
index 35fcd5b..bd4436a 100644
--- a/src/widget/owned.rs
+++ b/src/widget/owned.rs
@@ -156,6 +156,8 @@ impl<W: WidgetHost + 'static> WidgetHost for Owned<W> {
fn focus_role(&self) -> FocusRole { self.widget.focus_role() }
fn a11y_role(&self) -> Option<accesskit::Role> { self.widget.a11y_role() }
fn a11y_value(&self) -> Option<String> { self.widget.a11y_value() }
+ fn a11y_range(&self) -> Option<(f64, f64, f64)> { self.widget.a11y_range() }
+ fn a11y_set_value(&mut self, value: f64) -> bool { self.widget.a11y_set_value(value) }
fn corner_radii(&self) -> CornerRadii { self.widget.corner_radii() }
}