GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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