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

commit9d4545d0f2b47051e6bc506b364d76875e714492
parentb176a6a684
authorLucas Galante <[email protected]>
date2026-09-08 20:53
fix(focus): reading order buckets stops into rows; a focused Toggle lights its label

A strict y-then-x sort put the gallery's 12px Checkbox, centred a few px
below the 26px Button and Toggle beside it, on a row of its own after the
Toggle. Stops now join a row while their top lies above the row's first
stop's bottom, and sort by x within the row.

The Toggle had no visible focus cue: its rocker halves carve partial rings
(the hinge wall is open), which cannot be tinted, and the label's focus
colour is an unset config key. Focused, its label lights in the highlight.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                    |  2 ++
 src/context.rs               | 34 ++++++++++++++++++++++++++--------
 src/widget/input/checkbox.rs | 11 ++++++++++-
 3 files changed, 38 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2de2f1a..987846b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -145,6 +145,8 @@ What this buys, and where the code is heading:
   The focus ring is the plate's own silhouette: `ControlPlate::with_tint`
   lights the rim (a tinted `Trough`, `Boss` or `Bevel`), the same treatment a
   well's `recess_tinted` gives its rim while editing — never extra geometry.
+  A Checkbox lights the ring its mark already draws; a Toggle lights its
+  label (its rocker halves carve partial rings, which cannot be tinted).
   Roles today: Button, Checkbox, Toggle, Dropdown, FontSelector are plates;
   TextBox, Spinbox, ColorSelector, KeybindRecorder, TreeList are wells. A new
   focusable widget declares its role and handles `FocusIn` / `FocusOut`.
diff --git a/src/context.rs b/src/context.rs
index 9ab9560..57ffefe 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -577,7 +577,8 @@ impl UiContext {
     /// Returns whether focus moved. The runner calls this for Tab when the app
     /// opts in (`Application::plate_navigation`).
     pub fn focus_step(&mut self, reverse: bool) -> bool {
-        let mut stops: Vec<(i32, i32, WidgetId)> = Vec::new();
+        // (y, bottom, x, id) per stop.
+        let mut found: Vec<(f32, f32, f32, WidgetId)> = Vec::new();
         for (id, ptr) in self.tree.iter_registered() {
             if ptr.is_null() {
                 continue;
@@ -590,21 +591,37 @@ impl UiContext {
             if width <= 0.0 || height <= 0.0 {
                 continue;
             }
-            stops.push((y.round() as i32, x.round() as i32, id));
+            found.push((y, y + height, x, id));
         }
-        if stops.is_empty() {
+        if found.is_empty() {
             return false;
         }
-        stops.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
+        // Reading order: rows first, x within a row. A stop joins the current
+        // row when its top lies above the row's first stop's bottom — a 12px
+        // checkbox centred a few px below the 26px button beside it is on the
+        // button's row, not a row of its own.
+        found.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
+        let mut rows: Vec<Vec<(f32, f32, f32, WidgetId)>> = Vec::new();
+        for s in found {
+            match rows.last_mut() {
+                Some(row) if s.0 < row[0].1 => row.push(s),
+                _ => rows.push(vec![s]),
+            }
+        }
+        let mut stops: Vec<WidgetId> = Vec::new();
+        for mut row in rows {
+            row.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
+            stops.extend(row.into_iter().map(|s| s.3));
+        }
         let n = stops.len();
-        let current = self.focused_widget.and_then(|f| stops.iter().position(|s| s.2 == f));
+        let current = self.focused_widget.and_then(|f| stops.iter().position(|s| *s == f));
         let next = match (current, reverse) {
             (Some(i), false) => (i + 1) % n,
             (Some(i), true) => (i + n - 1) % n,
             (None, false) => 0,
             (None, true) => n - 1,
         };
-        let id = stops[next].2;
+        let id = stops[next];
         if self.focused_widget == Some(id) {
             return false;
         }
@@ -1199,8 +1216,9 @@ mod focus_step_tests {
         let mut a = Button::new(0.0, 0.0, 80.0, 24.0).with_label("A");
         let mut b = Button::new(0.0, 0.0, 80.0, 24.0).with_label("B");
         let mut t = TextBox::new("well".to_string());
-        // Placed out of registration order: b is right of a on the first row, t below.
-        WidgetHost::set_rect(&mut b, 100.0, 10.0, 80.0, 24.0);
+        // Placed out of registration order: b is right of a on the first row (and a
+        // few px lower — a shorter control centred on the row, still the same row), t below.
+        WidgetHost::set_rect(&mut b, 100.0, 16.0, 80.0, 12.0);
         WidgetHost::set_rect(&mut a, 10.0, 10.0, 80.0, 24.0);
         WidgetHost::set_rect(&mut t, 10.0, 50.0, 200.0, 24.0);
         for w in [&mut b as &mut dyn WidgetHost, &mut a, &mut t] {
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 1f3101c..f36effa 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -541,12 +541,21 @@ impl Paint for Toggle {
                     Justification::Center => x + (w - est_w) / 2.0,
                 }
             };
+            // Focused: the label lit in the highlight — a rocker's halves carve
+            // partial rings (the hinge wall is open), which cannot be tinted, so
+            // the label is the toggle's focus cue.
+            let label_color = if self.focused {
+                let c = colors::to_srgb(crate::color::highlight_primary_color());
+                [(c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8]
+            } else {
+                colors::control_label_color_for_state(self.hovered, false)
+            };
             ctx.text(
                 label.clone(),
                 tx,
                 crate::layout::align_text_y(y, h, font_size, 0.0),
                 font_size,
-                colors::control_label_color_for_state(self.hovered, self.focused),
+                label_color,
             );
         }
     }