git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitdbc3e3ec1f921a7c351dfd445905bc59b999388d
parentd704c8ba87
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 10:51
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() }
 }