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

commit42901a7d1fbd86518d7afe707f11bd085a3a482e
parent95800b35cf
authorLucas Galante <[email protected]>
date2026-08-19 15:31
fix: multiline TextBox selection/caret grid uses the shaped advance, not inked extent

The multiline selection highlight, caret, click-to-column mapping, and
scroll-to-cursor all place columns at col * char_width(), which came from
measure_text_width("M") — an SVG rasterization that reports the glyph's
inked pixel extent and resolves generic families through fontdb. The text
itself is drawn by cosmic-text, positioned by font advance and resolved
through get_text_buffer's family logic. The two numbers disagree (inked
extent excludes side bearings; the resolvers can even pick different
faces), so the highlight drifted off the glyphs by a growing amount per
column — very visible on cce-data-editor's long KDL lines.

prepare_text now shapes an 8-M probe through the same buffer path that
draws the value text (buffer-cached, so a lookup after the first frame)
and caches the per-column advance; char_width() prefers it. For a
monospace family the grid is now exact.

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

 src/widget/input/text_box.rs | 22 +++++++++++++++++++++-
 1 file changed, 21 insertions(+), 1 deletion(-)

diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index dd10d90..1d648e9 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -79,6 +79,12 @@ pub struct TextBox {
     pub cursor_x_offset: f32,
     pub glyph_positions: Vec<f32>,
     pub total_text_width: f32,
+    /// The shaped advance of one column, cached by [`Paint::prepare_text`] from the same
+    /// cosmic-text path that draws the value text. `char_width()` prefers this over the
+    /// SVG-rasterized `measure_text_width`, which reports inked extent (and resolves generic
+    /// families through fontdb, not cosmic-text) — a per-column error that made the multiline
+    /// selection highlight drift off the glyphs. 0.0 until the first `prepare_text`.
+    shaped_char_advance: f32,
     pub update_on_type: bool,
     /// Synced control label ([`Paint::sync_label`]) — drives the side/detached offsets.
     label: Option<String>,
@@ -126,6 +132,7 @@ impl TextBox {
             cursor_x_offset: 0.0,
             glyph_positions: Vec::new(),
             total_text_width: 0.0,
+            shaped_char_advance: 0.0,
             update_on_type: false,
             label: None,
             hovered: false,
@@ -171,7 +178,11 @@ impl TextBox {
     }
 
     pub fn char_width(&self) -> f32 {
-        crate::widget::display::measure_text_width("M", &self.font_family, self.font_size)
+        if self.shaped_char_advance > 0.0 {
+            self.shaped_char_advance
+        } else {
+            crate::widget::display::measure_text_width("M", &self.font_family, self.font_size)
+        }
     }
 
     pub fn line_height(&self) -> f32 {
@@ -1198,6 +1209,15 @@ impl Paint for TextBox {
         let mut total_w: f32 = 0.0;
         let scale = crate::scale::scale_factor().max(1.0);
 
+        // One column's advance, from the same shaping path as the labels (buffer-cached,
+        // so this is a lookup after the first frame per family/size).
+        let probe = crate::widget::display::text_label::make_widget_text_buffer(fs, "MMMMMMMM", self.font_size, font_fam);
+        self.shaped_char_advance = probe
+            .layout_runs()
+            .next()
+            .and_then(|run| run.glyphs.last().map(|g| (g.x + g.w) / scale / 8.0))
+            .unwrap_or(0.0);
+
         for run in buffer.layout_runs() {
             for glyph in run.glyphs {
                 let byte_offset = glyph.start;