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

commit64db5c9b8476b8bb2c343409bbe5d36d1995044c
parent67ea356b85
authorLucas Galante <[email protected]>
date2026-09-01 14:56
text: the runner shapes every registered widget, so carets stop drifting per-path

A widget's caret, selection, and click->index math read per-glyph advances
its prepare_text records — but only the flat path (render_widget) and apps
that hand-shape ever called it. Display-list apps got nothing: the paint
walk is &dyn, so cce-list, cce-secrets, cce-authenticator,
cce-display-manager, cce-system-interface, cce-test-interface, and the
reference DemoApp itself all fell back to measure_text_width("M") — an
SVG-rasterized inked extent whose per-char error accumulates (verified in a
shadow session: 22 chars left the old cce-list caret a full character short
of the text; the swept build sits flush). Hand-shaping apps that refreshed
only on events (cce-fonts, cce-data-editor, cce-layout-interface) could go
stale between edits, too.

render() now walks the app's registered widgets before asking for the frame
and calls prepare_text against the SAME FontSystem the glyph pass draws
with. Every path shapes; apps' own calls remain harmless cache lookups.

Three widgets never shaped at all, on any path, and are moved onto the same
shaped metrics the renderer draws with:

- Spinbox: caret and click->index used a hardcoded 8.4 px/char (three
  copies of the caret math, one of the click). prepare_text records
  char->x cluster offsets of the drawn value (size 14, default family).
- ColorSelector: the caret measured its prefix through the SVG
  measure_text path. prepare_text records offsets of the drawn hex
  (size 12, default family); the old measure remains only as the
  never-shaped fallback.
- ParametersBg: the code editor's caret and click->column used hardcoded
  7.2 px/col; a shaped monospace@12 probe replaces it. The container now
  also forwards prepare_text to its hosted TextBoxes, Spinboxes, and
  ColorSelectors — children of a container the registry sweep can't see.

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

 src/backend/window_runner.rs          | 30 ++++++++++++++
 src/widget/container/parameters_bg.rs | 46 ++++++++++++++++++++-
 src/widget/input/color_selector.rs    | 39 +++++++++++++++++-
 src/widget/input/spinbox.rs           | 75 ++++++++++++++++++++++++++++++-----
 4 files changed, 176 insertions(+), 14 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 37db2c0..b4e4624 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3750,6 +3750,36 @@ impl<A: Application> EngineState<A> {
             }
         }
         
+        // 0. Shape every registered widget against the SAME FontSystem the glyph pass draws
+        // with, before the app builds its frame. A widget's caret/selection/click→index math
+        // reads per-glyph advances its `prepare_text` records; nothing else calls it on the
+        // display-list path (the paint walk is `&dyn`, and apps were left to remember —
+        // cce-list, cce-secrets, and the reference DemoApp all forgot, so their carets fell
+        // back to `measure_text_width("M")`, an inked extent that drifts off the glyphs).
+        // The flat path shapes in `layout::render_widget`; apps that hand-shape still work —
+        // their call and this one hit the same shaped-buffer cache. Pointers are collected
+        // first so the registry borrow ends before any widget is mutated (the missed-press
+        // walk dereferences the same registry the same way).
+        {
+            let ptrs: Vec<*mut (dyn crate::widget::WidgetHost + 'static)> = self
+                .inner
+                .as_ref()
+                .unwrap()
+                .ui_context()
+                .map(|ctx| ctx.tree.iter_registered().map(|(_, p)| p).collect())
+                .unwrap_or_default();
+            if !ptrs.is_empty() {
+                let fs = self.font_system.as_mut().unwrap();
+                for ptr in ptrs {
+                    unsafe {
+                        if let Some(w) = ptr.as_mut() {
+                            w.prepare_text(fs);
+                        }
+                    }
+                }
+            }
+        }
+
         // 1. The frame's geometry IS the app's display list — the single paint path. Tessellated
         // below as one batched, GPU-scissor-clipped pass. An app that draws nothing returns
         // `None`, giving an empty frame (the legacy view*/tuple-wrapping path is gone).
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 512bab7..77c9dad 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -74,6 +74,11 @@ pub struct ParametersBg {
     /// `tick`) — the shared [`crate::widget::ScrollbarActivity`], which was extracted FROM
     /// this widget so every app's plate-straddling scrollbar behaves the same way.
     activity: crate::widget::ScrollbarActivity,
+    /// One code-editor column's shaped advance (monospace @12, the family/size
+    /// the code rows draw in), recorded by [`Paint::prepare_text`]. The caret
+    /// and click→column math read it; the hardcoded 7.2 px/col they used
+    /// before drifted off the glyphs. 0.0 until the first shape.
+    code_char_advance: f32,
 }
 
 /// The channel: the ONLY gap a control keeps from whatever its edge meets — the
@@ -136,6 +141,16 @@ const CONTROL_INSET: f32 = CHANNEL;
 const ROW_X_INSET: f32 = SECTION_MARGIN + CONTROL_INSET;
 
 impl ParametersBg {
+    /// One code column's width — the shaped advance when recorded, else the
+    /// legacy 7.2 estimate (only before the first `prepare_text`).
+    fn code_col_w(&self) -> f32 {
+        if self.code_char_advance > 0.0 {
+            self.code_char_advance
+        } else {
+            7.2
+        }
+    }
+
     pub fn new() -> Adapted<ParametersBg> {
         Adapted::new(ParametersBg {
             rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
@@ -157,6 +172,7 @@ impl ParametersBg {
             visible: true,
             scroll_y: 0.0,
             content_h: 0.0,
+            code_char_advance: 0.0,
             scrollbar_dragging: false,
             drag_offset_y: 0.0,
             activity: crate::widget::ScrollbarActivity::new(),
@@ -932,7 +948,7 @@ impl ParametersBg {
                 if self.focused_param == Some(i) {
                     if let Some(ref editor) = self.code_editor {
                         let (cursor_l, cursor_c) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
-                        let cursor_x = r.0 + 12.0 + (cursor_c as f32 * 7.2);
+                        let cursor_x = r.0 + 12.0 + (cursor_c as f32 * self.code_col_w());
                         let cursor_y = r.1 + 22.0 + (cursor_l as f32 * 16.0) + (16.0 - 13.0) / 2.0;
                         if cursor_y >= r.1 + 18.0 && cursor_y + 13.0 <= r.1 + r.3 {
                             param_quads.push((cursor_x, cursor_y, 1.5, 13.0, [0.80, 0.80, 0.85, 1.0]));
@@ -1396,6 +1412,32 @@ impl Layout for ParametersBg {
 }
 
 impl Paint for ParametersBg {
+    /// Shape the hosted controls (their carets read per-glyph advances nothing
+    /// else records for children of a container) and one code column's advance
+    /// from the same monospace@12 path the code rows draw through.
+    fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
+        for tb in self.texts.iter_mut().flatten() {
+            tb.prepare_text(fs);
+        }
+        for sb in self.spinboxes.iter_mut().flatten() {
+            sb.prepare_text(fs);
+        }
+        for c in self.colors.iter_mut().flatten() {
+            c.prepare_text(fs);
+        }
+        let clusters = crate::backend::window_runner::shaped_cluster_offsets(
+            fs,
+            "MMMMMMMM",
+            12.0,
+            Some("monospace"),
+        );
+        if let Some(&(_, total)) = clusters.last() {
+            if total > 0.0 {
+                self.code_char_advance = total / 8.0;
+            }
+        }
+    }
+
     /// The panel IS its own background plate (the host draws it from `color()` + the corner
     /// style via `push_widget_vertices`) — there is no separate plate widget behind it, so
     /// this carries the full plate treatment: `PARAM_BG` scaled by the global plate opacity,
@@ -2196,7 +2238,7 @@ impl Input for ParametersBg {
                                 let click_x = px - (r.0 + 12.0);
                                 let click_y = py - (r.1 + 22.0);
                                 let line = (click_y / 16.0).floor().max(0.0) as usize;
-                                let col = (click_x / 7.2 + 0.5).floor().max(0.0) as usize;
+                                let col = (click_x / self.code_col_w() + 0.5).floor().max(0.0) as usize;
                                 editor.cursor_idx = map_2d_to_1d(&editor.buffer, line, col);
                                 self.code_editor = Some(editor);
                                 clicked_any_focusable = true;
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index df83e47..03815e6 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -25,6 +25,12 @@ pub struct ColorSelector {
     live_rx: Option<std::sync::mpsc::Receiver<String>>,
     /// The value at picker launch, restored when the stream reports `cancel`.
     revert_hex: Option<String>,
+    /// Char-index → x offsets of the drawn hex text, recorded by
+    /// [`Paint::prepare_text`] from the same shaped buffer `ctx.text` draws
+    /// (size 12, default family). The caret reads these; the SVG-rasterized
+    /// `measure_text` prefix it used before reports inked extent, which drifts
+    /// off the glyph advances. Empty until the first shape.
+    glyph_offsets: Vec<f32>,
 }
 
 impl Clone for ColorSelector {
@@ -45,6 +51,7 @@ impl Clone for ColorSelector {
             with_alpha: self.with_alpha,
             live_rx: None,
             revert_hex: None,
+            glyph_offsets: self.glyph_offsets.clone(),
         }
     }
 }
@@ -67,6 +74,7 @@ impl ColorSelector {
             with_alpha: false,
             live_rx: None,
             revert_hex: None,
+            glyph_offsets: Vec::new(),
         })
     }
 
@@ -87,6 +95,7 @@ impl ColorSelector {
             with_alpha: true,
             live_rx: None,
             revert_hex: None,
+            glyph_offsets: Vec::new(),
         })
     }
 
@@ -134,6 +143,30 @@ impl Layout for ColorSelector {
 }
 
 impl Paint for ColorSelector {
+    fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
+        // Shape the drawn hex string exactly as `ctx.text` draws it (size 12,
+        // default family) and record char-index → x for the caret.
+        let text = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
+        let clusters =
+            crate::backend::window_runner::shaped_cluster_offsets(fs, &text, 12.0, None);
+        let mut offsets = vec![0.0f32; text.chars().count() + 1];
+        for (byte, x) in clusters {
+            let ci = text[..byte.min(text.len())].chars().count();
+            if ci < offsets.len() {
+                offsets[ci] = x;
+            }
+        }
+        let mut current = 0.0;
+        for off in offsets.iter_mut() {
+            if *off == 0.0 {
+                *off = current;
+            } else {
+                current = *off;
+            }
+        }
+        self.glyph_offsets = offsets;
+    }
+
     fn color(&self) -> [f32; 4] {
         colors::to_linear([
             self.color[0] as f32 / 255.0,
@@ -196,8 +229,10 @@ impl Paint for ColorSelector {
 
         if self.editing {
             let font_size = 12.0;
-            let cursor_text: String = self.edit_buffer.chars().take(self.cursor_idx).collect();
-            let text_w = crate::widget::display::measure_text(&cursor_text, font_size);
+            let text_w = self.glyph_offsets.get(self.cursor_idx).copied().unwrap_or_else(|| {
+                let cursor_text: String = self.edit_buffer.chars().take(self.cursor_idx).collect();
+                crate::widget::display::measure_text(&cursor_text, font_size)
+            });
             let caret_x = rect.x + 4.0 + text_w;
             let caret_h = font_size * 1.15;
             let caret_y = rect.y + (visual_h - caret_h) / 2.0;
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 3f8bf1b..3480401 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -36,6 +36,11 @@ pub struct Spinbox {
     pub editor_state: TextEditorState,
     pub just_changed: bool,
     label: Option<String>,
+    /// Char-index → x offsets of the value text, recorded by [`Paint::prepare_text`]
+    /// from the same shaped buffer the renderer draws (`ctx.text`, size 14, default
+    /// family). The caret and click→index math read these; the `8.4` px/char guess
+    /// they used before drifted off the glyphs. Empty until the first shape.
+    glyph_offsets: Vec<f32>,
 }
 
 /// The zone geometry shared by paint and input, derived from the content rect.
@@ -69,9 +74,38 @@ impl Spinbox {
             editor_state: TextEditorState::new(String::new()),
             just_changed: false,
             label: None,
+            glyph_offsets: Vec::new(),
         })
     }
 
+    /// The caret x offset for a char index, from the shaped offsets when present
+    /// (falling back to the legacy estimate only if nothing shaped yet).
+    fn caret_offset(&self, idx: usize) -> f32 {
+        self.glyph_offsets
+            .get(idx)
+            .copied()
+            .unwrap_or(idx as f32 * 8.4)
+    }
+
+    /// Click x (relative to the text origin) → char index, nearest shaped offset.
+    fn x_to_idx(&self, relative_x: f32) -> usize {
+        if self.glyph_offsets.is_empty() {
+            return ((relative_x / 8.4).round() as isize)
+                .max(0)
+                .min(self.edit_buffer.chars().count() as isize) as usize;
+        }
+        let mut closest = 0;
+        let mut min_diff = f32::MAX;
+        for (i, &pos) in self.glyph_offsets.iter().enumerate() {
+            let diff = (pos - relative_x).abs();
+            if diff < min_diff {
+                min_diff = diff;
+                closest = i;
+            }
+        }
+        closest
+    }
+
     pub fn set_unit(&mut self, unit: &str) {
         self.unit = Some(unit.to_string());
     }
@@ -251,6 +285,31 @@ impl Layout for Spinbox {
 }
 
 impl Paint for Spinbox {
+    fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
+        // Shape the displayed value exactly as `ctx.text` draws it (size 14,
+        // default family) and record char-index → x. Cluster offsets arrive
+        // keyed by byte; the editor state is char-indexed.
+        let text = self.value_text();
+        let clusters =
+            crate::backend::window_runner::shaped_cluster_offsets(fs, &text, 14.0, None);
+        let mut offsets = vec![0.0f32; text.chars().count() + 1];
+        for (byte, x) in clusters {
+            let ci = text[..byte.min(text.len())].chars().count();
+            if ci < offsets.len() {
+                offsets[ci] = x;
+            }
+        }
+        let mut current = 0.0;
+        for off in offsets.iter_mut() {
+            if *off == 0.0 {
+                *off = current;
+            } else {
+                current = *off;
+            }
+        }
+        self.glyph_offsets = offsets;
+    }
+
     fn color(&self) -> [f32; 4] {
         [0.0, 0.0, 0.0, 0.0]
     }
@@ -315,8 +374,7 @@ impl Paint for Spinbox {
                         Rect { x: g.x + 4.0, y: g.y + g.h - 4.0, width: g.w * 0.55 - 8.0, height: 1.5 },
                         accent,
                     );
-                    let char_width = 8.4;
-                    let cursor_x = (g.x + 4.0 + self.cursor_idx as f32 * char_width).min(g.x + g.w * 0.55 - 4.0);
+                    let cursor_x = (g.x + 4.0 + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
                     let cursor_y = g.y + (g.h - 14.0) / 2.0;
                     ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
                 }
@@ -358,8 +416,7 @@ impl Paint for Spinbox {
                 );
             }
             if self.editing {
-                let char_width = 8.4;
-                let cursor_x = (g.x + 4.0 + self.cursor_idx as f32 * char_width).min(g.x + g.w * 0.55 - 4.0);
+                let cursor_x = (g.x + 4.0 + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
                 let cursor_y = g.y + (g.h - 14.0) / 2.0;
                 ctx.rounded_rect(
                     Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 },
@@ -381,8 +438,7 @@ impl Paint for Spinbox {
                 ctx.quad(Rect { x: g.x, y: g.y, width: 1.0, height: g.h }, border_color);
                 ctx.quad(Rect { x: g.x + g.w - 1.0, y: g.y, width: 1.0, height: g.h }, border_color);
 
-                let char_width = 8.4;
-                let cursor_x = (g.x + 4.0 + self.cursor_idx as f32 * char_width).min(g.x + g.w * 0.55 - 4.0);
+                let cursor_x = (g.x + 4.0 + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
                 let cursor_y = g.y + (g.h - 14.0) / 2.0;
                 ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
             }
@@ -438,10 +494,9 @@ impl Input for Spinbox {
                     true
                 } else if *px < g.split_dec {
                     self.begin_edit(false);
-                    let char_width = 8.4;
-                    self.cursor_idx = (((px - (g.x + 4.0)) / char_width).round() as isize)
-                        .max(0)
-                        .min(self.edit_buffer.chars().count() as isize) as usize;
+                    self.cursor_idx = self
+                        .x_to_idx(px - (g.x + 4.0))
+                        .min(self.edit_buffer.chars().count());
                     ectx.request_focus();
                     true
                 } else {