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

commit7e0e642f9965014e72124f7bda0eec5d8fb1cece
parent35a2e92132
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-09 01:16
feat(a11y): a text field is read and set as text (SetTextContents)

A field says what a reader reads and edits (Input::a11y_text ->
a11y::A11yText: what it shows, its caret and selection while edited,
multi-line, password, editable, placeholder), and the tree publishes it
as text runs -- one per line, the break at its end, each character's
UTF-8 length -- with the selection in them, instead of a plain value.
Runs are what AccessKit gives AT-SPI's Text and EditableText interfaces
for. SetTextContents arrives as SetValue with text and is
Input::a11y_set_text (a11y_unix::set_value): replaced as the user would
replace it, undoable, the caret at its end, reported as a typed change.

- A multi-line box is a MultilineTextInput; a password box a
  PasswordInput whose runs are bullets. Until this its secret was the
  node's value, in the clear.
- A disabled field is read-only; the placeholder rides as AT-SPI's
  placeholder-text.
- The tree list's add-key box is hidden while its popover is closed: it
  was a field a reader found and a Tab stop nobody could see.

Verified over AT-SPI on cce-data-editor (private session and
accessibility buses): text and caret read back, the search box set to
"grid" filtering the tree, the source set to new KDL re-parsed. Shadow
A/B of the add-key popover and the Tab walk: identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                        |   6 +-
 docs/rfc-accessibility-locale.md |  26 ++++++-
 src/a11y.rs                      | 164 +++++++++++++++++++++++++++++++++++++--
 src/backend/a11y_unix.rs         |  61 +++++++++++++--
 src/widget/container/treelist.rs |  36 +++++++++
 src/widget/input/text_box.rs     |  57 ++++++++++++++
 src/widget/mod.rs                |  10 +++
 src/widget/model.rs              |  16 ++++
 8 files changed, 361 insertions(+), 15 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index db09734..ab588a4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1895,8 +1895,10 @@ cce-system-interface) to confirm behavior, not just the test suite.
   `WidgetHost::a11y_role` / `a11y_value` are what a widget says about itself), the nodes an
   app without widgets declares (`Application::accessibility`, `AppNodes`), and an open context
   menu; a widget's parts of its own (a radio group's radio buttons) are `A11yItem`s
-  (`Input::a11y_items`). `backend::a11y_unix` publishes it over AT-SPI (the `a11y` feature; see
-  `docs/rfc-accessibility-locale.md`, phase 2).
+  (`Input::a11y_items`), and a text field's text is TEXT RUNS with its caret
+  (`Input::a11y_text` → `A11yText`; a password as bullets), which is what lets a reader read
+  it by line and set it (`Input::a11y_set_text`). `backend::a11y_unix` publishes it over
+  AT-SPI (the `a11y` feature; see `docs/rfc-accessibility-locale.md`, phase 2).
 - `l10n.rs` — the toolkit's catalogue (`tr`, `tr_args`, `catalog`) over `cce_core::l10n`;
   its English is `locale/en-US/cce-ui.ftl`.
 - `style.rs` — the style snapshot: `Style`, `StyleCell`, `batch`, `style_slots!`.
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index b694b6d..afcd6e7 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -162,9 +162,29 @@ exist for the macOS and Windows adapters. So:
   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.
+back), spin buttons set to 7 and 3 with their ranges read.
+
+**A text field is read and set as text (2026-10-09).** A field says what a reader reads and
+edits (`Input::a11y_text` → `a11y::A11yText`: what it shows, its caret and selection while
+it is edited, multi-line, password, editable, placeholder), and the tree publishes it as
+TEXT RUNS — one per line, the line's break at its end, each character's UTF-8 length beside
+it — with the selection in them, instead of a plain value. Runs are what AccessKit gives
+AT-SPI's Text and EditableText interfaces for: a reader reads the field by character and
+line, follows the caret, and sets it with `SetTextContents`, which arrives as `SetValue`
+with text and is `Input::a11y_set_text` (`backend::a11y_unix::set_value`): the text
+replaced as the user replacing it would — undoable, the caret at its end — and reported to
+the app as a typed change. A multi-line box is a `MultilineTextInput`, a password box a
+`PasswordInput` whose runs are bullets; until this its secret was the node's value, in the
+clear. A disabled field is read-only. `TextBox` implements both; the placeholder rides as
+AT-SPI's `placeholder-text`. Verified on cce-data-editor, on a private session bus with its
+own accessibility bus (a plain `dbus-daemon`, `at-spi2-registryd`, and a stand-in
+`org.a11y.Bus` reporting it enabled — so the desktop's own AT-SPI stays off): the source
+editor's whole text and caret read back, the search box set to "grid" filtering the tree to
+`grid_gap`, the source set to new KDL re-parsed into the tree. The add-key popover's box,
+there whether its popover was open or not, is hidden while it is closed: it was a field a
+reader found and a Tab stop nobody could see.
+
+Next: the macOS adapter onto the AppKit view, 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 2a5c576..63aa6a9 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -11,6 +11,9 @@
 //! - **name** — its label;
 //! - **value** — [`WidgetHost::a11y_value`] (a widget's `value_string`): a check box or switch
 //!   as toggled, a slider, spin button or progress bar as a number, anything else as text;
+//!   a text field ([`WidgetHost::a11y_text`]) has its text as TEXT RUNS instead, one per
+//!   line, with its caret and selection — what gives it AT-SPI's Text and EditableText
+//!   interfaces, so a reader reads it by character and line and can set it;
 //! - **bounds** — its rect in LOGICAL px, the window node carrying the HiDPI scale as its
 //!   transform, so no widget's bounds change with the scale;
 //! - **actions** — focus for every keyboard stop, click for every [`FocusRole::Plate`];
@@ -31,7 +34,7 @@
 //! raise events only for nodes that changed; sending changed nodes alone is an optimisation
 //! for later, with `backend::frame`'s damage diff as its model.
 
-use accesskit::{Action, Affine, Node, NodeId, Rect, Role, Toggled, TreeId, TreeInfo, TreeUpdate};
+use accesskit::{Action, Affine, Node, NodeId, Rect, Role, TextPosition, TextSelection, Toggled, TreeId, TreeInfo, TreeUpdate};
 
 use crate::context::UiContext;
 use crate::widget::{FocusRole, NamedKey, WidgetHost, WidgetId, WidgetHostExt};
@@ -63,6 +66,75 @@ pub struct A11yItem {
 /// nodes and above every widget's.
 const ITEM_BASE: u64 = 1 << 60;
 
+/// A text field's text as a reader reads and edits it (`Input::a11y_text`).
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct A11yText {
+    /// What the field shows: a password field's as one bullet a character, never the secret.
+    pub text: String,
+    /// While it is being edited, the selection's anchor and its focus (the caret), as char
+    /// indices into `text`; equal for a bare caret.
+    pub selection: Option<(usize, usize)>,
+    pub multiline: bool,
+    pub password: bool,
+    /// Whether a reader may set it (a disabled field may not).
+    pub editable: bool,
+    /// What it shows while empty ("Search..."): a hint, and the field's only words when it
+    /// has no label.
+    pub placeholder: Option<String>,
+}
+
+/// A text field's text runs take the item indices from here up, one per line, so they never
+/// meet a widget's own items ([`A11yItem`]), which stay below.
+const RUN_BASE: usize = 0x8000;
+
+/// The node of line `line`'s text run in widget `id`'s field ([`A11yText`]).
+pub fn text_run_id(id: WidgetId, line: usize) -> NodeId {
+    item_id(id, RUN_BASE + line.min(RUN_BASE - 1))
+}
+
+/// A field's text as AccessKit's text runs: one per line, each line's break at its end, the
+/// characters as the field counts them (chars). A text ending in a break has an empty last
+/// line, where a caret after it stands. Past `RUN_BASE` lines the rest is one run.
+fn text_lines(text: &str) -> Vec<&str> {
+    let mut lines: Vec<&str> = text.split_inclusive('\n').collect();
+    if text.is_empty() || text.ends_with('\n') {
+        lines.push("");
+    }
+    if lines.len() > RUN_BASE {
+        let start: usize = lines[..RUN_BASE - 1].iter().map(|l| l.len()).sum();
+        lines.truncate(RUN_BASE - 1);
+        lines.push(&text[start..]);
+    }
+    lines
+}
+
+/// Char index `at` of a field's text as a position in its runs.
+fn run_position(id: WidgetId, lines: &[&str], at: usize) -> TextPosition {
+    let mut start = 0;
+    for (i, line) in lines.iter().enumerate() {
+        let len = line.chars().count();
+        if at < start + len || i + 1 == lines.len() {
+            return TextPosition { node: text_run_id(id, i), character_index: at.saturating_sub(start).min(len) };
+        }
+        start += len;
+    }
+    TextPosition { node: text_run_id(id, 0), character_index: 0 }
+}
+
+/// The text run nodes of widget `id`'s field, in order.
+pub fn text_run_nodes(id: WidgetId, text: &A11yText) -> Vec<(NodeId, Node)> {
+    text_lines(&text.text)
+        .into_iter()
+        .enumerate()
+        .map(|(i, line)| {
+            let mut run = Node::new(Role::TextRun);
+            run.set_value(line);
+            run.set_character_lengths(line.chars().map(|c| c.len_utf8() as u8).collect::<Vec<u8>>());
+            (text_run_id(id, i), run)
+        })
+        .collect()
+}
+
 /// The node of item `idx` of widget `id` ([`A11yItem`]).
 pub fn item_id(id: WidgetId, idx: usize) -> NodeId {
     NodeId(ITEM_BASE + ((id.0 as u64) << 16) + (idx as u64 & 0xffff))
@@ -167,13 +239,34 @@ pub fn role_for(type_name: &str, focus: FocusRole, explicit: Option<Role>) -> Ro
 /// One widget's node, with `children` already resolved.
 pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
     let focus = w.focus_role();
-    let role = role_for(w.type_name(), focus, w.a11y_role());
+    let text = w.a11y_text();
+    let role = match (&text, role_for(w.type_name(), focus, w.a11y_role())) {
+        (Some(t), Role::TextInput) if t.password => Role::PasswordInput,
+        (Some(t), Role::TextInput) if t.multiline => Role::MultilineTextInput,
+        (_, role) => role,
+    };
     let mut node = Node::new(role);
     let name = w.base().accessible_name.clone().filter(|n| !n.is_empty());
     if let Some(label) = name.or_else(|| w.label().filter(|l| !l.is_empty())) {
         node.set_label(label);
     }
-    if let Some(value) = w.a11y_value() {
+    if let Some(t) = &text {
+        // The text is the runs (`text_run_nodes`, the node's first children); the selection
+        // is in them.
+        let id = w.base().id();
+        if let Some((anchor, focus)) = t.selection {
+            let lines = text_lines(&t.text);
+            node.set_text_selection(TextSelection { anchor: run_position(id, &lines, anchor), focus: run_position(id, &lines, focus) });
+        }
+        if let Some(hint) = t.placeholder.as_deref().filter(|h| !h.is_empty()) {
+            node.set_placeholder(hint);
+        }
+        if t.editable {
+            node.add_action(Action::SetValue);
+        } else {
+            node.set_read_only();
+        }
+    } else if let Some(value) = w.a11y_value() {
         match role {
             Role::CheckBox | Role::Switch => {
                 if let Some(on) = truth(&value) {
@@ -290,8 +383,15 @@ pub fn window_tree(ctx: Option<&UiContext>, app: AppNodes, title: &str, scale: f
     // The keyboard's node: a focused widget's, or its focused item's.
     let mut item_focus: Option<NodeId> = None;
     for (id, w) in &widgets {
-        // The widget's own items come first, then its linked children.
+        // A text field's runs come first, then the widget's own items, then its linked
+        // children.
         let mut children: Vec<NodeId> = Vec::new();
+        if let Some(text) = w.a11y_text() {
+            for (nid, run) in text_run_nodes(*id, &text) {
+                nodes.push((nid, run));
+                children.push(nid);
+            }
+        }
         for (i, item) in w.a11y_items().into_iter().enumerate() {
             let nid = item_id(*id, i);
             let mut node = Node::new(item.role);
@@ -537,7 +637,8 @@ mod tests {
         assert_eq!(b.bounds(), Some(Rect::new(10.0, 40.0, 90.0, 64.0)), "logical px");
 
         let t = node(&update, node_id(name.id()));
-        assert_eq!((t.role(), t.label(), t.value()), (Role::TextInput, Some("Name"), Some("Ada")));
+        assert_eq!((t.role(), t.label(), t.value()), (Role::TextInput, Some("Name"), None), "a field's text is its runs");
+        assert_eq!(node(&update, t.children()[0]).value(), Some("Ada"));
         assert!(!t.supports_action(Action::Click), "a well is not pressed");
 
         let c = node(&update, node_id(wrap.id()));
@@ -548,6 +649,59 @@ mod tests {
         assert!(s.numeric_value().is_some(), "a slider's value is a number");
     }
 
+    /// A text field is its text runs, a line each with the line's break at its end, every
+    /// character's byte length beside it; while it is edited its selection is in the runs. A
+    /// reader may set it (AT-SPI's EditableText) unless it is disabled, and a password field's
+    /// runs are bullets: the secret is nowhere in the tree.
+    #[test]
+    fn a_text_field_is_its_text_runs() {
+        let mut ctx = UiContext::new();
+        let notes = ctx.insert(TextBox::new("héllo\nwo".to_string()).with_multiline(true).with_label("Notes"));
+        ctx[notes].set_rect(10.0, 10.0, 200.0, 80.0);
+        let pass = ctx.insert(TextBox::new("hunter2".to_string()).with_label("Password"));
+        ctx[pass].set_placeholder("Passphrase");
+        ctx[pass].is_password = true;
+        ctx[pass].set_rect(10.0, 100.0, 200.0, 24.0);
+        let fixed = ctx.insert(TextBox::new("fixed".to_string()));
+        ctx[fixed].disabled = true;
+        ctx[fixed].set_rect(10.0, 130.0, 200.0, 24.0);
+        // Focused, a box opens with all of it selected: anchor at the start, caret at the end.
+        ctx.set_focused_id(notes.id());
+
+        let update = tree_update(&ctx, "", 1.0);
+        let t = node(&update, node_id(notes.id()));
+        assert_eq!(t.role(), Role::MultilineTextInput);
+        assert!(t.supports_action(Action::SetValue), "a reader may set it");
+        let runs: Vec<&Node> = t.children().iter().map(|&c| node(&update, c)).collect();
+        assert!(runs.iter().all(|r| r.role() == Role::TextRun));
+        assert_eq!(runs.iter().map(|r| r.value().unwrap()).collect::<Vec<_>>(), ["héllo\n", "wo"]);
+        assert_eq!(runs[0].character_lengths(), &[1, 2, 1, 1, 1, 1], "é is two bytes, the break one character");
+        let sel = t.text_selection().expect("being edited, it has a selection");
+        assert_eq!(sel.anchor, TextPosition { node: t.children()[0], character_index: 0 });
+        assert_eq!(sel.focus, TextPosition { node: t.children()[1], character_index: 2 }, "the caret at the end of the last line");
+
+        let p = node(&update, node_id(pass.id()));
+        assert_eq!(p.role(), Role::PasswordInput);
+        assert_eq!(p.placeholder(), Some("Passphrase"), "its hint");
+        assert_eq!(node(&update, p.children()[0]).value(), Some("\u{2022}".repeat(7).as_str()));
+        assert!(
+            update.nodes.iter().all(|(_, n)| !n.value().unwrap_or("").contains("hunter2") && !n.label().unwrap_or("").contains("hunter2")),
+            "the secret is in no node",
+        );
+
+        let f = node(&update, node_id(fixed.id()));
+        assert!(f.is_read_only() && !f.supports_action(Action::SetValue), "a disabled field is read-only");
+        assert_eq!(f.text_selection(), None, "not being edited, it has no caret");
+
+        // A text that ends in a line break has an empty last line, where the caret after it is.
+        ctx[notes].a11y_set_text("a\n");
+        let update = tree_update(&ctx, "", 1.0);
+        let t = node(&update, node_id(notes.id()));
+        assert_eq!(t.children().len(), 2);
+        assert_eq!(node(&update, t.children()[1]).value(), Some(""));
+        assert_eq!(t.text_selection().unwrap().focus, TextPosition { node: t.children()[1], character_index: 0 });
+    }
+
     #[test]
     fn hidden_widgets_are_not_in_the_tree_and_focus_falls_back_to_the_window() {
         let mut ctx = UiContext::new();
diff --git a/src/backend/a11y_unix.rs b/src/backend/a11y_unix.rs
index 6327926..f54393d 100644
--- a/src/backend/a11y_unix.rs
+++ b/src/backend/a11y_unix.rs
@@ -72,7 +72,9 @@ pub enum Acted {
 ///   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
+///   (`WidgetHost::a11y_set_value`); with text (`SetTextContents` on a text field's
+///   EditableText) it replaces the field's text (`WidgetHost::a11y_set_text`). Either is
+///   marked changed for the app's `take_change` ([`set_value`]). 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 a widget's item (a radio button, `a11y::A11yItem`) does what a press on it
@@ -116,10 +118,7 @@ pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
     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 };
+        return set_value(ctx, id, request.data.as_ref());
     }
     let Some(w) = ctx.get_widget(id) else { return Acted::Nothing };
     let key = match request.action {
@@ -136,6 +135,19 @@ pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
     key.map_or(Acted::Changed, Acted::Key)
 }
 
+/// A reader's `SetValue` on widget `id`, where it is, focus untouched (a reader adjusting a
+/// value has not moved): a number for a slider or spin button (`WidgetHost::a11y_set_value`),
+/// text for a text field (AT-SPI's `SetTextContents`, `WidgetHost::a11y_set_text`).
+pub fn set_value(ctx: &mut crate::context::UiContext, id: crate::widget::WidgetId, data: Option<&ActionData>) -> Acted {
+    let Some(w) = ctx.get_widget_mut(id) else { return Acted::Nothing };
+    let changed = match data {
+        Some(ActionData::NumericValue(value)) => w.a11y_set_value(*value),
+        Some(ActionData::Value(text)) => w.a11y_set_text(text),
+        _ => false,
+    };
+    if changed { Acted::Changed } else { Acted::Nothing }
+}
+
 #[cfg(feature = "a11y")]
 mod imp {
     use super::Event;
@@ -223,3 +235,42 @@ mod imp {
 }
 
 pub use imp::Publisher;
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::context::UiContext;
+    use crate::widget::{Spinbox, TextBox};
+
+    /// A reader's SetValue reaches a text field as text (AT-SPI's `SetTextContents`) and a
+    /// spin button as a number; each is reported as the user's change would be, and neither
+    /// takes the other's kind. The same text again, or a disabled field, changes nothing.
+    #[test]
+    fn a_reader_sets_a_field_by_text_and_a_spin_button_by_number() {
+        let mut ctx = UiContext::new();
+        let name = ctx.insert(TextBox::new("Ada".to_string()));
+        let count = ctx.insert(Spinbox::new(3, 0, 10, 1));
+        let text = |s: &str| ActionData::Value(s.into());
+
+        assert_eq!(set_value(&mut ctx, name.id(), Some(&text("Grace"))), Acted::Changed);
+        assert_eq!(ctx[name].text, "Grace");
+        assert!(ctx[name].take_change(), "the app hears of it as of a typed change");
+        assert_eq!(set_value(&mut ctx, name.id(), Some(&text("Grace"))), Acted::Nothing, "unchanged");
+        assert_eq!(set_value(&mut ctx, name.id(), Some(&ActionData::NumericValue(4.0))), Acted::Nothing);
+
+        assert_eq!(set_value(&mut ctx, count.id(), Some(&ActionData::NumericValue(7.0))), Acted::Changed);
+        assert_eq!(set_value(&mut ctx, count.id(), Some(&text("2"))), Acted::Nothing, "a spin button is set by number");
+
+        // Being edited, the box takes it as a replacement it can undo, the caret at its end.
+        ctx.set_focused_id(name.id());
+        assert_eq!(set_value(&mut ctx, name.id(), Some(&text("Ada Lovelace"))), Acted::Changed);
+        let t = ctx[name].a11y_text().unwrap();
+        assert_eq!((t.text.as_str(), t.selection), ("Ada Lovelace", Some((12, 12))));
+        assert!(ctx[name].context_action(crate::widget::ContextAction::Undo), "undoable");
+        assert_eq!(ctx[name].a11y_text().unwrap().text, "Grace");
+
+        ctx[name].disabled = true;
+        assert_eq!(set_value(&mut ctx, name.id(), Some(&text("Hopper"))), Acted::Nothing, "a disabled field");
+        assert_eq!(set_value(&mut ctx, crate::widget::WidgetId(usize::MAX), Some(&text("x"))), Acted::Nothing, "nothing there");
+    }
+}
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 89fb86d..91c4e82 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -498,6 +498,7 @@ impl TreeList {
                 self.add_key_popover_open = !self.add_key_popover_open;
                 if self.add_key_popover_open {
                     let b = self.add_key_popover_box.get_mut(ui);
+                    WidgetHost::set_visible(b, true);
                     b.text.clear();
                     b.edit_buffer.clear();
                     b.cursor_idx = 0;
@@ -766,6 +767,13 @@ impl Layout for TreeList {
         self.search_box.get_mut(ctx).set_rect(r.search_box.x, r.search_box.y, r.search_box.width, r.search_box.height);
         self.add_key_btn.get_mut(ctx).set_rect(r.add_key_btn.x, r.add_key_btn.y, r.add_key_btn.width, r.add_key_btn.height);
         self.add_key_popover_box.get_mut(ctx).set_rect(r.popover_box.x, r.popover_box.y, r.popover_box.width, r.popover_box.height);
+        // The popover's box is shown only while the popover is: hidden, it is no Tab stop
+        // and no field a screen reader finds, where it stood in both, drawn or not.
+        let open = self.add_key_popover_open;
+        let b = self.add_key_popover_box.get_mut(ctx);
+        if WidgetHost::visible(b) != open {
+            WidgetHost::set_visible(b, open);
+        }
     }
 
     fn release_embedded_children(&mut self, ctx: &mut UiContext) {
@@ -1719,6 +1727,34 @@ mod tests {
         assert_eq!((t.search_box.get(&ctx).rect(), t.add_key_btn.get(&ctx).rect()), held);
     }
 
+    /// The add-key popover's box is in the window only while the popover is open: closed,
+    /// it is no Tab stop and no field a screen reader finds; the add-key button opens it,
+    /// and focuses it.
+    #[test]
+    fn the_add_key_box_is_there_only_while_its_popover_is() {
+        let mut ctx = UiContext::new();
+        let tl = ctx.insert(TreeList::new());
+        WidgetHost::set_rect(&mut ctx[tl], 0.0, 0.0, 400.0, 300.0);
+        ctx.tick(0.016);
+        let box_id = ctx[tl].add_key_popover_box.id();
+        let in_tree = |ctx: &UiContext| crate::a11y::tree_update(ctx, "", 1.0).nodes.iter().any(|(n, _)| *n == crate::a11y::node_id(box_id));
+        assert!(!ctx.get_widget(box_id).unwrap().visible(), "closed, the box is hidden");
+        assert!(!in_tree(&ctx), "and not in the accessibility tree");
+        for _ in 0..4 {
+            ctx.focus_step(false);
+            assert!(!ctx.is_focused_id(box_id), "nor a Tab stop");
+        }
+
+        let b = ctx[tl].field_rects().add_key_btn;
+        let (x, y) = (b.x + b.width / 2.0, b.y + b.height / 2.0);
+        let at = |state| Event::MouseButton { button: MouseButton::Left, state, x, y, local_x: x, local_y: y };
+        ctx.propagate_event(&at(ElementState::Pressed), tl.id());
+        ctx.propagate_event(&at(ElementState::Released), tl.id());
+        assert!(ctx[tl].add_key_popover_open, "the button opens the popover");
+        assert!(ctx.get_widget(box_id).unwrap().visible() && ctx.is_focused_id(box_id), "its box shown and focused");
+        assert!(in_tree(&ctx));
+    }
+
     #[test]
     fn test_treelist_blocks_window_drag() {
         // root plate container is DELETED: dissolved windows ask `drag_allowed_at` instead — same
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index ce588db..98cd9f0 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -2134,6 +2134,63 @@ impl Input for TextBox {
         self.set_value(val)
     }
 
+    fn a11y_text(&self) -> Option<crate::a11y::A11yText> {
+        // What the box holds, never an input method's provisional run; a password as bullets.
+        let held = if self.editing { self.committed_buffer() } else { self.text.clone() };
+        let len = held.chars().count();
+        let text = if self.is_password { "\u{2022}".repeat(len) } else { held };
+        // The caret and anchor are indices into what is shown; a composition sits at the
+        // caret, so past its start they come back to it.
+        let held_idx = |i: usize| {
+            let i = match self.composing {
+                Some((start, n)) if i > start => i.saturating_sub(n).max(start),
+                _ => i,
+            };
+            i.min(len)
+        };
+        let selection = self.editing.then(|| {
+            let focus = held_idx(self.cursor_idx);
+            (self.select_anchor.map_or(focus, held_idx), focus)
+        });
+        Some(crate::a11y::A11yText {
+            text,
+            selection,
+            multiline: self.multiline,
+            password: self.is_password,
+            editable: !self.disabled,
+            placeholder: self.placeholder.clone(),
+        })
+    }
+
+    fn a11y_set_text(&mut self, text: &str) -> bool {
+        if self.disabled {
+            return false;
+        }
+        self.abandon_composition();
+        let held = if self.editing { self.committed_buffer() } else { self.text.clone() };
+        if held == text {
+            return false;
+        }
+        if self.editing {
+            let before = self.snapshot();
+            self.history.record(before);
+        } else {
+            self.history.clear();
+        }
+        self.text = text.to_string();
+        self.edit_buffer = text.to_string();
+        self.just_changed = true;
+        self.cursor_idx = text.chars().count();
+        self.select_anchor = None;
+        self.all_selected = false;
+        self.sync_editor_state();
+        self.clamp_scroll();
+        if self.editing {
+            self.scroll_to_cursor();
+        }
+        true
+    }
+
     fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
         use crate::widget::ContextAction as CA;
         match action {
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 30c1307..e8d58ee 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -530,6 +530,16 @@ pub trait WidgetHostExt: WidgetHost {
         self.input_model_mut().a11y_set_value(value)
     }
 
+    /// A text field's text, caret and selection for a reader (`Input::a11y_text`).
+    fn a11y_text(&self) -> Option<crate::a11y::A11yText> {
+        self.input_model().a11y_text()
+    }
+
+    /// Replace a text field's text for an assistive tool (`Input::a11y_set_text`).
+    fn a11y_set_text(&mut self, text: &str) -> bool {
+        self.input_model_mut().a11y_set_text(text)
+    }
+
     /// An assistive tool clicked one of them (`Input::a11y_select_item`).
     fn a11y_select_item(&mut self, idx: usize) -> bool {
         self.input_model_mut().a11y_select_item(idx)
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 28a6b1b..08cae6a 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -457,6 +457,22 @@ pub trait Input {
         false
     }
 
+    /// The text a reader reads and edits, when this widget is a text field: what it shows,
+    /// its caret and selection (`crate::a11y::A11yText`). The tree publishes it as text runs,
+    /// which is what gives the field AT-SPI's Text and EditableText interfaces. Default
+    /// `None`: not a text field (its [`value_string`](Input::value_string) is its value).
+    fn a11y_text(&self) -> Option<crate::a11y::A11yText> {
+        None
+    }
+
+    /// Replace the field's text with what an assistive tool asked for (AT-SPI's
+    /// `SetTextContents`), as the user replacing it would — undoable, the caret at its end —
+    /// and mark it changed, so the host's `take_change` reports it. Returns whether it
+    /// changed. Default: not settable.
+    fn a11y_set_text(&mut self, _text: &str) -> bool {
+        false
+    }
+
     /// 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