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

commitbbec9777bb97e6549bc2b8099aa4361662c2d9e8
parentdbc3e3ec1f
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 11:01
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() }