GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(a11y): every typing field is a text field; apps publish their own
- A field that publishes text is a text input to the platform whatever
the widget would be otherwise (only a text input has EditableText).
ColorSelector answers a11y_text with its hex and a11y_set_text with
what typing a hex does, described as a "colour" (A11yText::kind).
As a description, not a role description: AccessKit makes a node with
one AT-SPI's Extended role, and such a node never registered on the
bus -- the gallery's colour selectors vanished from the reader.
- A field an app draws (a LineEdit) is AppNodes::text_field among the
app's own nodes, its runs in a range of their own (APP_RUN_BASE). A
reader's edit or request for the keyboard arrives as
Application::accessibility_action(n, AppAction); the app's own nodes
took no action at all until this. LineEdit::a11y_text /
a11y_set_text (undoable; a masked field keeps no history).
- AccessKit is re-exported as cce_ui::accesskit for apps' own nodes.
Spinbox stays a spin button: a number, set through AT-SPI's Value.
Verified over AT-SPI on private buses: cce-browser's address bar set
from a reader, the gallery's colour selector set to #20c060 (swatch
green) and a non-colour refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 8 +-
docs/rfc-accessibility-locale.md | 22 +++++
locale/en-US/cce-ui.ftl | 4 +
src/a11y.rs | 185 +++++++++++++++++++++++++++++++------
src/backend/a11y_unix.rs | 37 ++++++++
src/backend/app.rs | 12 +++
src/lib.rs | 3 +
src/widget/input/color_selector.rs | 28 ++++++
src/widget/input/text_box.rs | 1 +
src/widget/line_edit.rs | 53 +++++++++++
10 files changed, 325 insertions(+), 28 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ab588a4..87fc1ad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1897,8 +1897,12 @@ cce-system-interface) to confirm behavior, not just the test suite.
menu; a widget's parts of its own (a radio group's radio buttons) are `A11yItem`s
(`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).
+ it by line and set it (`Input::a11y_set_text`; `TextBox`, `ColorSelector`). A field an app
+ draws (a `LineEdit`) is `AppNodes::text_field`, and a reader's edit of it arrives as
+ `Application::accessibility_action` (`AppAction`). Never give a node a ROLE DESCRIPTION:
+ AccessKit makes it AT-SPI's `Extended` role, and the node never registers on the bus.
+ `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 afcd6e7..69ab7da 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -184,6 +184,28 @@ editor's whole text and caret read back, the search box set to "grid" filtering
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.
+**And so is every field where a person types (2026-10-09).** A field is published as a
+text input whatever the widget would be otherwise, since only a text input has
+EditableText: the `ColorSelector` answers `a11y_text` with its hex and `a11y_set_text` with
+what typing a hex does (a colour it parses, or nothing), described as a "colour"
+(`A11yText::kind`). As a DESCRIPTION, not a role description: AccessKit gives a node with a
+role description AT-SPI's `Extended` role, and such a node never registered on the bus —
+the gallery's two colour selectors were simply missing from the reader until it moved. A
+`Spinbox` stays a spin button: it is a number, which AT-SPI's Value interface sets
+(`SetCurrentValue`), as it already did. A field an APP draws — a `LineEdit` — is
+`AppNodes::text_field(n, label, &edit.a11y_text(has_keyboard), bounds)` among the app's own
+nodes (`Application::accessibility`; AccessKit is re-exported as `cce_ui::accesskit` for
+them), its runs in a range of their own (`APP_RUN_BASE`); a reader's edit, or its request
+for the keyboard, arrives as `Application::accessibility_action(n, AppAction::SetText(..) /
+Focus)` — the app's own nodes took no action at all until this — and `LineEdit::a11y_set_text`
+replaces the text undoably (a masked field keeps no history). cce-browser publishes its
+address bar, the bookmarks search, the vi command line and a dialog (modal, titled, its
+message as description) with its fields, and builds cce-ui's `a11y` feature, publishing
+with `CCE_A11Y=1` (not by default yet). Verified over AT-SPI on private buses: the
+browser's address bar set from the reader — the bar unfolds, takes the keyboard and shows
+it, the caret at its end — and the gallery's colour selector set to `#20c060`, its swatch
+green, "not a colour" refused.
+
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
diff --git a/locale/en-US/cce-ui.ftl b/locale/en-US/cce-ui.ftl
index 4b02094..921fc88 100644
--- a/locale/en-US/cce-ui.ftl
+++ b/locale/en-US/cce-ui.ftl
@@ -41,3 +41,7 @@ keybind-none = None
## The document editor's Properties table
doc-properties = Properties
doc-empty = Empty
+
+## Accessibility
+# What a colour selector's hex field is, said by a screen reader after its name.
+a11y-colour = colour
diff --git a/src/a11y.rs b/src/a11y.rs
index 63aa6a9..4710ee8 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -81,6 +81,11 @@ pub struct A11yText {
/// What it shows while empty ("Search..."): a hint, and the field's only words when it
/// has no label.
pub placeholder: Option<String>,
+ /// What kind of field it is, in words, when "text field" says too little: a colour
+ /// selector's hex field is a "colour". Published as the node's description, not a role
+ /// description: AccessKit makes a node with one AT-SPI's `Extended` role, and such a
+ /// node never registered on the bus (2026-10-09) — the field was gone from the reader.
+ pub kind: Option<String>,
}
/// A text field's text runs take the item indices from here up, one per line, so they never
@@ -108,21 +113,21 @@ fn text_lines(text: &str) -> Vec<&str> {
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 {
+/// Char index `at` of a field's text as a position in its runs, named by `run_id`.
+fn run_position(run_id: &impl Fn(usize) -> NodeId, 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) };
+ return TextPosition { node: run_id(i), character_index: at.saturating_sub(start).min(len) };
}
start += len;
}
- TextPosition { node: text_run_id(id, 0), character_index: 0 }
+ TextPosition { node: run_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)> {
+/// A field's text runs, in order, named by `run_id` (the line's number).
+fn run_nodes(text: &A11yText, run_id: impl Fn(usize) -> NodeId) -> Vec<(NodeId, Node)> {
text_lines(&text.text)
.into_iter()
.enumerate()
@@ -130,11 +135,48 @@ pub fn text_run_nodes(id: WidgetId, text: &A11yText) -> Vec<(NodeId, Node)> {
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)
+ (run_id(i), run)
})
.collect()
}
+/// The text run nodes of widget `id`'s field, in order.
+pub fn text_run_nodes(id: WidgetId, text: &A11yText) -> Vec<(NodeId, Node)> {
+ run_nodes(text, |line| text_run_id(id, line))
+}
+
+/// The role a field that publishes `text` has: a text input of its kind, whatever the
+/// widget would be otherwise — only a text input has AT-SPI's EditableText.
+fn text_field_role(text: &A11yText) -> Role {
+ if text.password {
+ Role::PasswordInput
+ } else if text.multiline {
+ Role::MultilineTextInput
+ } else {
+ Role::TextInput
+ }
+}
+
+/// What a field's node says of its text, beside the runs that hold it: the selection in
+/// them, its hint and kind, and whether a reader may set it.
+fn describe_text_field(node: &mut Node, text: &A11yText, run_id: impl Fn(usize) -> NodeId) {
+ if let Some((anchor, focus)) = text.selection {
+ let lines = text_lines(&text.text);
+ node.set_text_selection(TextSelection { anchor: run_position(&run_id, &lines, anchor), focus: run_position(&run_id, &lines, focus) });
+ }
+ if let Some(hint) = text.placeholder.as_deref().filter(|h| !h.is_empty()) {
+ node.set_placeholder(hint);
+ }
+ if let Some(kind) = text.kind.as_deref().filter(|k| !k.is_empty()) {
+ node.set_description(kind);
+ }
+ if text.editable {
+ node.add_action(Action::SetValue);
+ } else {
+ node.set_read_only();
+ }
+}
+
/// 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))
@@ -148,7 +190,7 @@ pub fn item_of(node: NodeId) -> Option<(WidgetId, usize)> {
/// The context-menu row a node is, when it is one ([`menu_row_id`]'s inverse).
pub fn menu_row_of(node: NodeId) -> Option<usize> {
- (node.0 > MENU.0).then(|| (node.0 - MENU.0 - 1) as usize)
+ (node.0 > MENU.0 && node.0 < APP_RUN_BASE).then(|| (node.0 - MENU.0 - 1) as usize)
}
/// The open context menu's node, and the first of its rows' (`MENU + 1 + row`): far above any
@@ -163,6 +205,36 @@ pub fn menu_row_id(row: usize) -> NodeId {
/// Where an app's own nodes ([`AppNodes::id`]) begin: above every widget's, below the menu's.
const APP_BASE: u64 = 1 << 61;
+/// Where the text runs of an app's own fields ([`AppNodes::text_field`]) begin: above the
+/// menu's rows. `APP_RUN_BASE + (n << 15) + line` for the app's node `n`.
+const APP_RUN_BASE: u64 = 1 << 63;
+
+/// The node of line `line`'s text run in the app's own field `n`.
+fn app_text_run_id(n: u64, line: usize) -> NodeId {
+ NodeId(APP_RUN_BASE + ((n & ((1 << 48) - 1)) << 15) + line.min(RUN_BASE - 1) as u64)
+}
+
+/// The app's own number for a node it declared ([`AppNodes::id`]'s inverse).
+pub fn app_node_of(node: NodeId) -> Option<u64> {
+ (node.0 >= APP_BASE && node.0 < MENU.0).then(|| node.0 - APP_BASE)
+}
+
+/// What an assistive tool asks of one of the app's own nodes
+/// (`Application::accessibility_action`).
+#[derive(Debug, Clone, PartialEq)]
+pub enum AppAction {
+ /// Put the keyboard on it.
+ Focus,
+ /// Press it.
+ Click,
+ /// Replace a field's text (AT-SPI's `SetTextContents`): as the user replacing it would.
+ SetText(String),
+ /// Set a value (AT-SPI's `SetCurrentValue`).
+ SetNumber(f64),
+ Increment,
+ Decrement,
+}
+
/// The nodes an app declares itself, for what it draws without a [`UiContext`] — see
/// [`Application::accessibility`](crate::backend::app::Application::accessibility). Ids come
/// from [`AppNodes::id`], an app's own numbering; children are set on a node with
@@ -196,6 +268,31 @@ impl AppNodes {
pub fn set_focus(&mut self, id: NodeId) {
self.focus = Some(id);
}
+
+ /// A text field the app draws itself — a `LineEdit` (`LineEdit::a11y_text`) — as node
+ /// `n`: a text input named `label`, at `bounds` (window px), whose text is runs this
+ /// pushes, so a reader reads it by character, follows its caret and sets it. The
+ /// field's node is returned for the app to place (`push_top`, or `push` under one of
+ /// its nodes) at [`AppNodes::id`]`(n)`. A reader's edit arrives as
+ /// `AppAction::SetText` on `n` (`Application::accessibility_action`), and a request
+ /// for the keyboard as `AppAction::Focus`.
+ pub fn text_field(&mut self, n: u64, label: &str, text: &A11yText, bounds: Option<crate::scene::layout::Rect>) -> Node {
+ let mut node = Node::new(text_field_role(text));
+ if !label.is_empty() {
+ node.set_label(label);
+ }
+ if let Some(r) = bounds {
+ node.set_bounds(Rect::new(r.x as f64, r.y as f64, (r.x + r.width) as f64, (r.y + r.height) as f64));
+ }
+ node.add_action(Action::Focus);
+ let runs = run_nodes(text, |line| app_text_run_id(n, line));
+ node.set_children(runs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
+ for (id, run) in runs {
+ self.push(id, run);
+ }
+ describe_text_field(&mut node, text, |line| app_text_run_id(n, line));
+ node
+ }
}
/// A widget's node: its id, moved up one so no widget can be the window.
@@ -240,10 +337,9 @@ pub fn role_for(type_name: &str, focus: FocusRole, explicit: Option<Role>) -> Ro
pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
let focus = w.focus_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 role = match &text {
+ Some(t) => text_field_role(t),
+ None => role_for(w.type_name(), focus, w.a11y_role()),
};
let mut node = Node::new(role);
let name = w.base().accessible_name.clone().filter(|n| !n.is_empty());
@@ -251,21 +347,9 @@ pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
node.set_label(label);
}
if let Some(t) = &text {
- // The text is the runs (`text_run_nodes`, the node's first children); the selection
- // is in them.
+ // The text is the runs (`text_run_nodes`, the node's first children).
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();
- }
+ describe_text_field(&mut node, t, |line| text_run_id(id, line));
} else if let Some(value) = w.a11y_value() {
match role {
Role::CheckBox | Role::Switch => {
@@ -702,6 +786,55 @@ mod tests {
assert_eq!(t.text_selection().unwrap().focus, TextPosition { node: t.children()[1], character_index: 0 });
}
+ /// A colour selector's hex field is a text field too, said to be a colour.
+ #[test]
+ fn a_colour_selectors_hex_is_a_colour_field() {
+ let mut ctx = UiContext::new();
+ let c = ctx.insert(crate::widget::ColorSelector::new([0x40, 0x80, 0xff]).with_label("Accent"));
+ ctx[c].set_rect(10.0, 10.0, 200.0, 24.0);
+ let update = tree_update(&ctx, "", 1.0);
+ let n = node(&update, node_id(c.id()));
+ assert_eq!((n.role(), n.label(), n.description()), (Role::TextInput, Some("Accent"), Some("colour")));
+ assert_eq!(n.role_description(), None, "a role description would make it AT-SPI's Extended role, which never registers");
+ assert!(n.supports_action(Action::SetValue));
+ assert_eq!(node(&update, n.children()[0]).value(), Some("#4080ff"));
+ }
+
+ /// A field an app draws itself (a `LineEdit`) is published as one of its own nodes: a
+ /// text input whose runs and caret are where a widget's would be, its secret bulleted,
+ /// its runs' ids clear of every other kind of node.
+ #[test]
+ fn an_app_drawn_field_is_a_text_field_of_the_apps() {
+ use crate::widget::LineEdit;
+ let mut url = LineEdit::with_text("héllo.org");
+ url.cursor = 3; // after the é, a two-byte char: the caret is char 2
+ let mut pass = LineEdit::masked();
+ pass.a11y_set_text("hunter2");
+ let mut app = AppNodes::default();
+ let mut t = url.a11y_text(true);
+ t.placeholder = Some("Search or enter address".into());
+ let field = app.text_field(7, "Address", &t, Some(crate::scene::layout::Rect { x: 0.0, y: 0.0, width: 300.0, height: 30.0 }));
+ app.push_top(AppNodes::id(7), field);
+ let secret = app.text_field(8, "Password", &pass.a11y_text(false), None);
+ app.push_top(AppNodes::id(8), secret);
+ app.set_focus(AppNodes::id(7));
+ let update = window_tree(None, app, "", 1.0);
+
+ let f = node(&update, AppNodes::id(7));
+ assert_eq!((f.role(), f.label(), f.placeholder()), (Role::TextInput, Some("Address"), Some("Search or enter address")));
+ assert!(f.supports_action(Action::SetValue) && f.supports_action(Action::Focus));
+ let run = f.children()[0];
+ assert_eq!(node(&update, run).value(), Some("héllo.org"));
+ assert_eq!(f.text_selection().unwrap().focus, TextPosition { node: run, character_index: 2 });
+ assert_eq!(app_node_of(AppNodes::id(7)), Some(7));
+ assert_eq!((app_node_of(run), menu_row_of(run), widget_of(run), item_of(run)), (None, None, None, None), "a run is nobody's node");
+
+ let p = node(&update, AppNodes::id(8));
+ assert_eq!(p.role(), Role::PasswordInput);
+ assert_eq!(node(&update, p.children()[0]).value(), Some("\u{2022}".repeat(7).as_str()));
+ assert_eq!(p.text_selection(), None, "without the keyboard, no caret");
+ }
+
#[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 f54393d..105dc8a 100644
--- a/src/backend/a11y_unix.rs
+++ b/src/backend/a11y_unix.rs
@@ -81,6 +81,9 @@ pub enum Acted {
/// does (`WidgetHost::a11y_select_item`) and puts the keyboard on its widget.
/// - **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 on one of the app's own nodes (`Application::accessibility`) is the app's:
+/// `Application::accessibility_action`, in its own terms (`a11y::AppAction`) — a reader
+/// setting a field the app draws (`AppNodes::text_field`) is `AppAction::SetText`.
///
/// 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 —
@@ -115,6 +118,9 @@ pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
}
return Acted::Changed;
}
+ if let Some(n) = crate::a11y::app_node_of(request.target_node) {
+ return if app_action(request).is_some_and(|action| app.accessibility_action(n, action)) { Acted::Changed } else { Acted::Nothing };
+ }
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 {
@@ -135,6 +141,20 @@ pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
key.map_or(Acted::Changed, Acted::Key)
}
+/// A request on one of the app's own nodes, in the app's terms (`Application::accessibility_action`).
+fn app_action(request: &ActionRequest) -> Option<crate::a11y::AppAction> {
+ use crate::a11y::AppAction;
+ Some(match (request.action, request.data.as_ref()) {
+ (Action::Focus, _) => AppAction::Focus,
+ (Action::Click, _) => AppAction::Click,
+ (Action::SetValue, Some(ActionData::Value(text))) => AppAction::SetText(text.to_string()),
+ (Action::SetValue, Some(ActionData::NumericValue(value))) => AppAction::SetNumber(*value),
+ (Action::Increment, _) => AppAction::Increment,
+ (Action::Decrement, _) => AppAction::Decrement,
+ _ => return None,
+ })
+}
+
/// 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`).
@@ -269,8 +289,25 @@ mod tests {
assert!(ctx[name].context_action(crate::widget::ContextAction::Undo), "undoable");
assert_eq!(ctx[name].a11y_text().unwrap().text, "Grace");
+ // A colour selector takes a colour as text, and nothing that is not one.
+ let accent = ctx.insert(crate::widget::ColorSelector::new([0x40, 0x80, 0xff]));
+ assert_eq!(set_value(&mut ctx, accent.id(), Some(&text("#00ff00"))), Acted::Changed);
+ assert_eq!(ctx[accent].a11y_value().as_deref(), Some("#00ff00"));
+ assert_eq!(set_value(&mut ctx, accent.id(), Some(&text("green-ish"))), Acted::Nothing);
+
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");
}
+
+ /// What a reader asks of an app's own node reaches the app in its own terms.
+ #[test]
+ fn a_request_on_an_apps_node_is_the_apps_action() {
+ use crate::a11y::AppAction;
+ let req = |action, data| ActionRequest { action, target_tree: accesskit::TreeId::ROOT, target_node: crate::a11y::AppNodes::id(3), data };
+ assert_eq!(app_action(&req(Action::SetValue, Some(ActionData::Value("x.org".into())))), Some(AppAction::SetText("x.org".into())));
+ assert_eq!(app_action(&req(Action::SetValue, Some(ActionData::NumericValue(2.0)))), Some(AppAction::SetNumber(2.0)));
+ assert_eq!(app_action(&req(Action::Focus, None)), Some(AppAction::Focus));
+ assert_eq!(app_action(&req(Action::ScrollIntoView, None)), None);
+ }
}
diff --git a/src/backend/app.rs b/src/backend/app.rs
index b3aa206..729193f 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -342,6 +342,18 @@ pub trait Application: Sized + 'static {
let _ = nodes;
}
+ /// An assistive tool asked something of one of the app's own nodes
+ /// ([`accessibility`](Self::accessibility)), named by the app's own number `n`
+ /// (`AppNodes::id(n)`): a reader's edit of a field the app draws
+ /// (`AppAction::SetText`, for one published with `AppNodes::text_field`), a request
+ /// for the keyboard, a press. Do what the user doing it would, and answer whether
+ /// anything changed (the window is then redrawn and the tree republished). Default:
+ /// nothing.
+ fn accessibility_action(&mut self, n: u64, action: crate::a11y::AppAction) -> bool {
+ let _ = (n, action);
+ false
+ }
+
fn ui_context(&self) -> Option<&crate::context::UiContext> {
None
}
diff --git a/src/lib.rs b/src/lib.rs
index 21ba405..f34abca 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -59,6 +59,9 @@ pub mod colors {
/// Re-exporting also keeps every client on the one version cce-ui shapes with —
/// a `FontSystem` handed across the boundary must be the same type.
pub use cosmic_text;
+/// AccessKit, whose nodes an app pushes for what it draws itself
+/// (`Application::accessibility`, `a11y::AppNodes`), so it needs no dependency of its own.
+pub use accesskit;
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index 7f6ad90..b2c7b97 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -536,6 +536,34 @@ impl Input for ColorSelector {
Some(self.value_hex())
}
+ fn a11y_text(&self) -> Option<crate::a11y::A11yText> {
+ // The hex field: what it shows, its caret while it is being typed into.
+ let text = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
+ let caret = self.cursor_idx.min(text.chars().count());
+ Some(crate::a11y::A11yText {
+ text,
+ selection: self.editing.then_some((caret, caret)),
+ editable: true,
+ kind: Some(crate::l10n::tr("a11y-colour")),
+ ..Default::default()
+ })
+ }
+
+ fn a11y_set_text(&mut self, text: &str) -> bool {
+ // A colour, as the field takes one when typed: a hex it parses, or nothing.
+ let text = text.trim();
+ if self.set_value_string(text) {
+ return true;
+ }
+ // The colour it already holds, set while a half-typed hex is up: the field shows it.
+ if self.editing && parse_hex(text).is_some() && self.edit_buffer != self.value_hex() {
+ self.edit_buffer = self.value_hex();
+ self.cursor_idx = self.edit_buffer.chars().count();
+ return true;
+ }
+ false
+ }
+
fn set_value_string(&mut self, val: &str) -> bool {
if let Some(c) = parse_hex(val) {
let target_color = [c[0], c[1], c[2]];
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 98cd9f0..6e7842d 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -2159,6 +2159,7 @@ impl Input for TextBox {
password: self.is_password,
editable: !self.disabled,
placeholder: self.placeholder.clone(),
+ kind: None,
})
}
diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
index a7c859b..4331519 100644
--- a/src/widget/line_edit.rs
+++ b/src/widget/line_edit.rs
@@ -570,6 +570,41 @@ impl LineEdit {
true
}
+ /// The field for a screen reader (`a11y::AppNodes::text_field`): what it holds — a
+ /// masked field as bullets, never an input method's composition — and, while it has the
+ /// keyboard (`editing`), its selection and caret. The caller adds what the field does
+ /// not know: its placeholder, whether it may be set.
+ pub fn a11y_text(&self, editing: bool) -> crate::a11y::A11yText {
+ let chars = |byte: usize| self.text[..byte.min(self.text.len())].chars().count();
+ let len = self.text.chars().count();
+ let text = if self.masked { "\u{2022}".repeat(len) } else { self.text.clone() };
+ let selection = editing.then(|| match self.selection {
+ // A selection is normalized; the caret is at one of its ends.
+ Some((a, b)) if self.cursor == a => (chars(b), chars(a)),
+ Some((a, b)) => (chars(a), chars(b)),
+ None => (chars(self.cursor), chars(self.cursor)),
+ });
+ crate::a11y::A11yText { text, selection, password: self.masked, editable: true, ..Default::default() }
+ }
+
+ /// Replace the text with what a screen reader set (AT-SPI's `SetTextContents`), as the
+ /// user replacing it would: undoable (a masked field keeps no history), the caret at its
+ /// end, any composition dropped. Whether it changed.
+ pub fn a11y_set_text(&mut self, text: &str) -> bool {
+ self.drop_composition();
+ if self.text == text {
+ return false;
+ }
+ if !self.masked {
+ let before = self.snapshot();
+ self.history.record(before);
+ }
+ self.text = text.to_string();
+ self.cursor = self.text.len();
+ self.selection = None;
+ true
+ }
+
pub fn can_undo(&self) -> bool {
self.history.can_undo()
}
@@ -1326,4 +1361,22 @@ mod tests {
assert_eq!(e.handle_key(&ctrl("x")), EditOutcome::Ignored);
assert_eq!(e.text, "hunter2", "cut must not have removed it");
}
+
+ /// A reader's edit replaces the text as the user's would: the caret at its end, a
+ /// selection dropped, one undo step back — and none for a masked field, which keeps no
+ /// history. The same text again changes nothing.
+ #[test]
+ fn a_readers_edit_replaces_the_text_undoably() {
+ let mut e = LineEdit::with_text("old");
+ e.selection = Some((0, 3));
+ assert!(e.a11y_set_text("new.org"));
+ assert_eq!((e.text.as_str(), e.cursor, e.selection), ("new.org", 7, None));
+ assert!(!e.a11y_set_text("new.org"));
+ assert!(e.undo());
+ assert_eq!(e.text, "old");
+ let mut p = LineEdit::masked();
+ assert!(p.a11y_set_text("hunter2"));
+ assert!(!p.undo(), "no history for a secret");
+ assert_eq!(p.a11y_text(false).text, "\u{2022}".repeat(7));
+ }
}