GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(keyboard): the Tab walk is on by default (accessibility RFC phase 3)
Application::plate_navigation now answers true. An app without a UiContext is
untouched (the walk finds no stops and Tab reaches handle_key_input as
before); an app that gives Tab its own meaning returns false. A focused
widget that types Tab keeps it (Input::keeps_tab: a multi-line TextBox while
editing), and the group chord (Ctrl+Tab) still leaves it.
Checked in a shadow with two Tabs each: cce-list, cce-weather, the demo and
cce-relief ring their second stop; cce-text-editor has no stops and is
unchanged. The designer, the display manager and cce-notes opt out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 16 +++++++-----
docs/rfc-accessibility-locale.md | 16 +++++++++++-
src/backend/app.rs | 18 ++++++++------
src/backend/driver.rs | 53 +++++++++++++++++++++++++++++++++++++++-
src/widget/input/text_box.rs | 6 +++++
src/widget/mod.rs | 5 ++++
src/widget/model.rs | 12 +++++++++
src/widget/owned.rs | 1 +
8 files changed, 112 insertions(+), 15 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 964c7e6..ecfb76f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1210,12 +1210,16 @@ What this buys, and where the code is heading:
first member falls (`focus_clusters`), wrapping; `focus_step_group` jumps
between runs (input.kdl `focus_next_group` / `focus_prev_group`, defaults
`ctrl+tab` / `ctrl+shift+tab`). The runner calls them for Tab / Shift+Tab
- and the chords when the app opts
- in with `Application::plate_navigation` (default off, so an app that routes
- Tab itself — a terminal, a web view, its own field order — is undisturbed)
- and tells the app through `Application::focus_stepped` — an app that caches
- its geometry until its own rebuild flag raises it there. The walk needs the
- app's context exposed (`ui_context_mut`); the ring reaches flat-path hosts
+ and the chords unless the app opts OUT with `Application::plate_navigation`
+ (default ON since 2026-10-08, accessibility RFC phase 3; the designer, the
+ display manager and cce-notes give Tab meanings of their own and return
+ false), and tells the app through `Application::focus_stepped` — an app that caches
+ its geometry until its own rebuild flag raises it there. A focused widget
+ that types Tab keeps it (`Input::keeps_tab`: a multi-line `TextBox` while
+ editing), and the group chord leaves it. The walk needs the
+ app's context exposed (`ui_context_mut`), so an app without one (a terminal,
+ a web view) gets Tab as before; a widget registered but never drawn is a
+ stop nobody can see, so register a widget only while it is shown. The ring reaches flat-path hosts
through `RenderTarget::inset_plate_tinted` and `CarveKind::Boss { tint }`.
`CCE_FOCUS_DEBUG=1` prints the stops in walk order.
The focus ring is the plate's own silhouette: `ControlPlate::with_tint`
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index 9c119e7..57fbbda 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -36,7 +36,8 @@ the cheapest moment there will be.
**Keyboard.**
- Tab / Shift+Tab plate navigation exists (`UiContext::focus_step`, `focus_step_group`)
- but is OPT-IN (`Application::plate_navigation`, default false). Four apps turn it on:
+ but is OPT-IN (`Application::plate_navigation`, default false; on by default since phase
+ 3). Four apps turn it on:
cce-data-editor, cce-files, cce-gallery, cce-system-interface (plus the demo).
- There is no tooltip (where an accessible description usually lives), no modal dialog
widget (where focus must be trapped), and no radio group.
@@ -174,6 +175,19 @@ Orca.
### Phase 3 — keyboard first
+**Progress (2026-10-08): the walk is on by default.** `Application::plate_navigation`
+answers true. It changes nothing for an app without a `UiContext` (the walk finds no stops
+and Tab reaches `handle_key_input` as before: the terminal, the browser, the calendar and
+the other immediate-mode apps), so the apps it reached are the seven with a context that had
+not opted in. Three give Tab a meaning of their own and opt out: the designer (the node
+palette), the display manager (its username / password order) and cce-notes (accepting a
+link completion). A focused widget that types Tab keeps it (`Input::keeps_tab`: a
+multi-line `TextBox` while editing), and Ctrl+Tab still leaves it
+(`tab_walks_the_stops_but_a_multi_line_box_keeps_it`). Checked in a shadow, two Tabs each:
+cce-list, cce-weather, the demo and cce-relief ring their second stop; cce-fonts' first
+two stops were its picker buttons, registered outside picker mode and never drawn, and are
+now registered only in it; cce-text-editor has no stops and is unchanged.
+
- `plate_navigation` defaults to true; an app that routes Tab itself (a terminal, a web
view) opts OUT.
- A modal dialog widget that traps focus, a tooltip that doubles as the accessible
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 4916dd8..1160f40 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -443,14 +443,18 @@ pub trait Application: Sized + 'static {
false
}
- /// Opt into the toolkit's keyboard navigation in plate terms: Tab and
- /// Shift+Tab move focus to the next / previous plate or well in reading
- /// order (`UiContext::focus_step`), a press (Enter / Space) acts on the
- /// focused plate, a well opens for typing when focused. Default false: an
- /// app that routes Tab itself (a terminal, a web view, its own field
- /// order) is undisturbed. See "Plates, wells and seams" in `CLAUDE.md`.
+ /// The toolkit's keyboard navigation in plate terms: Tab and Shift+Tab move
+ /// focus to the next / previous plate or well in reading order
+ /// (`UiContext::focus_step`), a press (Enter / Space) acts on the focused
+ /// plate, a well opens for typing when focused. **On by default** (since
+ /// 2026-10-08, `docs/rfc-accessibility-locale.md` phase 3): the keyboard is
+ /// how a person who cannot use a pointer reaches anything. It needs the
+ /// app's `ui_context_mut`, so an app without a widget tree is untouched
+ /// (Tab reaches its `handle_key_input` as before); a focused widget that
+ /// takes Tab itself keeps it (`WidgetHost::keeps_tab`). An app that routes
+ /// Tab itself (its own field order, a completion popup) returns false.
fn plate_navigation(&self) -> bool {
- false
+ true
}
/// Wait for the NEXT compositor when this one goes away, instead of
diff --git a/src/backend/driver.rs b/src/backend/driver.rs
index 1871c46..f71d1b2 100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@ -811,6 +811,15 @@ impl Driver {
if !app.plate_navigation() {
return false;
}
+ // A focused widget that types Tab (a multi-line text box) keeps a bare Tab; the
+ // group chord still leaves it.
+ if bare_tab
+ && app.ui_context_mut().is_some_and(|ctx| {
+ ctx.focused_widget.and_then(|id| ctx.get_widget(id)).is_some_and(|w| w.keeps_tab())
+ })
+ {
+ return false;
+ }
let moved = app
.ui_context_mut()
.is_some_and(|ctx| if bare_tab { ctx.focus_step(reverse) } else { ctx.focus_step_group(reverse) });
@@ -936,6 +945,8 @@ mod tests {
takes_undo: bool,
/// A press makes this message, which `update` records.
press_msg: Option<u32>,
+ /// A widget tree, for the Tab walk; none by default, as most apps here.
+ ctx: Option<crate::context::UiContext>,
}
impl Application for Mock {
@@ -991,10 +1002,50 @@ mod tests {
fn csd_titlebar_move(&self) -> bool {
self.csd
}
+ fn ui_context_mut(&mut self) -> Option<&mut crate::context::UiContext> {
+ self.ctx.as_mut()
+ }
}
fn mock() -> Mock {
- Mock { seen: Vec::new(), csd: false, takes_undo: false, press_msg: None }
+ Mock { seen: Vec::new(), csd: false, takes_undo: false, press_msg: None, ctx: None }
+ }
+
+ /// The Tab walk is on unless an app says otherwise. Tab leaves a one-line field for the
+ /// next stop and never reaches the app; a multi-line box that is editing keeps it (the
+ /// app's key handling, which forwards it to the box, sees it) until Ctrl+Tab moves on.
+ #[test]
+ fn tab_walks_the_stops_but_a_multi_line_box_keeps_it() {
+ use crate::widget::{Owned, TextBox, WidgetHost};
+ let tab = Key::Named(NamedKey::Tab);
+ let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+ let mut ctx = crate::context::UiContext::new();
+ let mut name = Owned::new(TextBox::new(String::new()));
+ name.set_rect(10.0, 10.0, 200.0, 24.0);
+ let mut notes = Owned::new(TextBox::new(String::new()).with_multiline(true));
+ notes.set_rect(10.0, 50.0, 200.0, 120.0);
+ let mut save = Owned::new(crate::widget::Button::new(10.0, 200.0, 80.0, 24.0).with_label("Save"));
+ ctx.register_host(&mut name);
+ ctx.register_host(&mut notes);
+ ctx.register_host(&mut save);
+ app.ctx = Some(ctx);
+ assert!(app.plate_navigation(), "on by default");
+ let focused = |app: &mut Mock| app.ctx.as_ref().unwrap().focused_widget;
+
+ d.key(turn(&mut app, &mut f), tab.clone(), Some("\t".into()), ElementState::Pressed);
+ assert_eq!(focused(&mut app), Some(name.base().id()), "the first stop");
+ d.key(turn(&mut app, &mut f), tab.clone(), Some("\t".into()), ElementState::Pressed);
+ assert_eq!(focused(&mut app), Some(notes.base().id()), "Tab leaves a one-line field");
+ assert!(!app.seen.iter().any(|s| matches!(s, Seen::Key(k, _) if *k == tab)), "the walk took both");
+ assert!(notes.keeps_tab(), "focused, the multi-line box is editing");
+
+ d.key(turn(&mut app, &mut f), tab.clone(), Some("\t".into()), ElementState::Pressed);
+ assert_eq!(focused(&mut app), Some(notes.base().id()), "the box keeps Tab");
+ assert_eq!(app.seen.last(), Some(&Seen::Key(tab.clone(), false)), "and the app hands it on");
+
+ d.set_modifiers(&mut app, Modifiers { ctrl: true, shift: false, alt: false, logo: false });
+ d.key(turn(&mut app, &mut f), tab.clone(), None, ElementState::Pressed);
+ assert_eq!(focused(&mut app), Some(save.base().id()), "Ctrl+Tab leaves it");
}
/// A driver with known chords, whatever the machine's input.kdl says.
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index c441965..8d14d79 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1894,6 +1894,12 @@ impl Input for TextBox {
fn focus_role(&self) -> crate::widget::FocusRole {
crate::widget::FocusRole::Well
}
+
+ /// A multi-line box that is editing types Tab, as an editor does; a one-line field lets
+ /// the Tab walk take it (Tab leaves a field).
+ fn keeps_tab(&self) -> bool {
+ self.multiline && self.editing
+ }
/// Advances the wheel glide / trackpad coast behind the scroll offsets.
/// Cheap when idle (the common case); `wants_tick` is unconditional
/// because it is sampled once at registration.
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index ba0ca2b..174edf7 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -575,6 +575,11 @@ pub trait WidgetHost {
FocusRole::None
}
+ /// Whether, focused, it takes Tab itself instead of the Tab walk (`Input::keeps_tab`).
+ fn keeps_tab(&self) -> bool {
+ false
+ }
+
/// An explicit accessibility role, overriding the guess `crate::a11y::role_for` makes
/// from the widget's type and focus role. Default `None`.
fn a11y_role(&self) -> Option<accesskit::Role> {
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 2a1c88c..935f1d0 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -442,6 +442,14 @@ pub trait Input {
FocusRole::None
}
+ /// Whether, focused, this widget takes Tab itself, so the toolkit's Tab walk
+ /// (`Application::plate_navigation`) passes it the key instead of moving focus: a
+ /// multi-line text box that is editing types it. The group chord (`focus_next_group`,
+ /// Ctrl+Tab by default) still leaves it. Default false: Tab leaves a widget.
+ fn keeps_tab(&self) -> bool {
+ false
+ }
+
/// Container hit policy: hit whenever any [`Layout::child_visible`] child hits (Layer,
/// Switcher). The container's own rect is not consulted. Default: own-rect hit.
fn hits_through_children(&self) -> bool {
@@ -1302,6 +1310,10 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
Input::focus_role(&self.inner)
}
+ fn keeps_tab(&self) -> bool {
+ Input::keeps_tab(&self.inner)
+ }
+
fn a11y_role(&self) -> Option<accesskit::Role> {
Input::a11y_role(&self.inner)
}
diff --git a/src/widget/owned.rs b/src/widget/owned.rs
index bd4436a..97a865b 100644
--- a/src/widget/owned.rs
+++ b/src/widget/owned.rs
@@ -154,6 +154,7 @@ impl<W: WidgetHost + 'static> WidgetHost for Owned<W> {
fn blocks_root_plate_drag(&self) -> bool { self.widget.blocks_root_plate_drag() }
fn corner_style(&self) -> (f32, (bool, bool, bool, bool)) { self.widget.corner_style() }
fn focus_role(&self) -> FocusRole { self.widget.focus_role() }
+ fn keeps_tab(&self) -> bool { self.widget.keeps_tab() }
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() }