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

commitc98dfc4090ff43b5344b701ea0a6a3836d664a0d
parentee2d1f4a46
authorLucas Galante <[email protected]>
date2026-07-02 23:01
Implemented exact glyphon-shaping character offset measurement and mouse click index mapping inside TextBox, resolving all font fallback and monospace caret/selection misalignment issues

 src/widget/input/text_box.rs | 123 +++++++++++++++++++++++++++++++++++++------
 1 file changed, 107 insertions(+), 16 deletions(-)

diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 0f1951b..0daf813 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -42,6 +42,9 @@ pub struct TextBox {
     pub scroll_x: f32,
     default_font_size: f32,
     default_font_family: String,
+    pub cursor_x_offset: f32,
+    pub glyph_positions: Vec<f32>,
+    pub total_text_width: f32,
 }
 
 impl TextBox {
@@ -77,6 +80,9 @@ impl TextBox {
             scroll_x: 0.0,
             default_font_size: style_size,
             default_font_family: style_family,
+            cursor_x_offset: 0.0,
+            glyph_positions: Vec::new(),
+            total_text_width: 0.0,
         }
     }
 
@@ -85,6 +91,27 @@ impl TextBox {
         self
     }
 
+    fn map_x_to_idx(&self, click_x: f32) -> usize {
+        let relative_x = click_x - (self.base.x + 8.0) + self.scroll_x;
+        if self.glyph_positions.is_empty() {
+            let char_width = self.char_width();
+            return ((relative_x / char_width).round() as isize)
+                .max(0)
+                .min(self.edit_buffer.chars().count() as isize) as usize;
+        }
+        
+        let mut closest_idx = 0;
+        let mut min_diff = f32::MAX;
+        for (i, &pos) in self.glyph_positions.iter().enumerate() {
+            let diff = (pos - relative_x).abs();
+            if diff < min_diff {
+                min_diff = diff;
+                closest_idx = i;
+            }
+        }
+        closest_idx
+    }
+
     pub fn with_draw_bg_border(mut self, draw: bool) -> Self {
         self.draw_bg_border = draw;
         self
@@ -452,7 +479,7 @@ impl Default for TextBox {
 impl Element for TextBox {
     crate::impl_widget_base!(TextBox);
 
-    fn prepare_text(&mut self, _fs: &mut glyphon::FontSystem) {
+    fn prepare_text(&mut self, fs: &mut glyphon::FontSystem) {
         let (style_family, style_size) = crate::layout::textbox_font_parsed();
         if self.font_size == self.default_font_size {
             self.font_size = style_size;
@@ -463,6 +490,62 @@ impl Element for TextBox {
             self.font_family = style_family.clone();
         }
         self.default_font_family = style_family;
+
+        let text_src = if self.editing { &self.edit_buffer } else { &self.text };
+        let display_text = if text_src.is_empty() && self.placeholder.is_some() {
+            self.placeholder.as_ref().unwrap().as_str()
+        } else {
+            text_src.as_str()
+        };
+
+        let font_fam = if self.is_password {
+            "monospace"
+        } else {
+            self.font_family.as_str()
+        };
+
+        let render_text = if self.is_password {
+            "•".repeat(display_text.chars().count())
+        } else {
+            display_text.to_string()
+        };
+
+        let buffer = crate::widget::display::text_label::make_widget_text_buffer(fs, &render_text, self.font_size, font_fam);
+
+        let char_count = render_text.chars().count();
+        let mut x_offsets = vec![0.0; char_count + 1];
+        let mut total_w: f32 = 0.0;
+
+        for run in buffer.layout_runs() {
+            for glyph in run.glyphs {
+                let byte_offset = glyph.start;
+                let c_idx = render_text[..byte_offset.min(render_text.len())].chars().count();
+                if c_idx < x_offsets.len() {
+                    x_offsets[c_idx] = glyph.x;
+                }
+                total_w = total_w.max(glyph.x + glyph.w);
+            }
+        }
+
+        let mut current_x = 0.0;
+        for i in 0..x_offsets.len() {
+            if x_offsets[i] == 0.0 && i > 0 {
+                x_offsets[i] = current_x;
+            } else {
+                current_x = x_offsets[i];
+            }
+        }
+
+        if !x_offsets.is_empty() {
+            let last_idx = x_offsets.len() - 1;
+            x_offsets[last_idx] = total_w;
+        }
+
+        self.glyph_positions = x_offsets;
+        self.total_text_width = total_w;
+
+        let cursor_pos = self.cursor_idx.min(self.glyph_positions.len() - 1);
+        self.cursor_x_offset = self.glyph_positions.get(cursor_pos).copied().unwrap_or(0.0);
     }
 
     fn get_value_string(&self) -> Option<String> {
@@ -585,9 +668,7 @@ impl Element for TextBox {
                 let click_col = (((px - (self.base.x + 8.0) + self.scroll_x) / char_width).round() as isize).max(0) as usize;
                 self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
             } else {
-                (((px - (self.base.x + 8.0) + self.scroll_x) / char_width).round() as isize)
-                    .max(0)
-                    .min(self.edit_buffer.chars().count() as isize) as usize
+                self.map_x_to_idx(px)
             };
             if self.cursor_idx != drag_idx {
                 self.cursor_idx = drag_idx;
@@ -632,9 +713,7 @@ impl Element for TextBox {
             let click_col = (((px - (self.base.x + 8.0) + self.scroll_x) / char_width).round() as isize).max(0) as usize;
             self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
         } else {
-            (((px - (self.base.x + 8.0) + self.scroll_x) / char_width).round() as isize)
-                .max(0)
-                .min(self.edit_buffer.chars().count() as isize) as usize
+            self.map_x_to_idx(px)
         };
         if self.cursor_idx != drag_idx {
             self.cursor_idx = drag_idx;
@@ -686,9 +765,7 @@ impl Element for TextBox {
                         let click_col = (((px - (self.base.x + 8.0) + self.scroll_x) / char_width).round() as isize).max(0) as usize;
                         self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
                     } else {
-                        (((px - (self.base.x + 8.0) + self.scroll_x) / char_width).round() as isize)
-                            .max(0)
-                            .min(self.edit_buffer.chars().count() as isize) as usize
+                        self.map_x_to_idx(px)
                     };
                     self.cursor_idx = idx;
                     self.select_anchor = Some(idx);
@@ -1005,9 +1082,11 @@ impl Element for TextBox {
             } else {
                 let caret_h = self.font_size * 1.15;
                 if start != end {
-                    let highlight_x = self.base.x + 8.0 + (start as f32 * char_width) - self.scroll_x;
+                    let h_left_offset = self.glyph_positions.get(start).copied().unwrap_or_else(|| start as f32 * char_width);
+                    let h_right_offset = self.glyph_positions.get(end).copied().unwrap_or_else(|| end as f32 * char_width);
+                    let highlight_x = self.base.x + 8.0 + h_left_offset - self.scroll_x;
                     let h_left = highlight_x.max(self.base.x + 8.0);
-                    let h_right = (highlight_x + ((end - start) as f32 * char_width)).min(self.base.x + self.base.w - 8.0);
+                    let h_right = (self.base.x + 8.0 + h_right_offset - self.scroll_x).min(self.base.x + self.base.w - 8.0);
                     if h_left < h_right {
                         quads.push((
                             h_left,
@@ -1020,7 +1099,12 @@ impl Element for TextBox {
                 }
 
                 if self.editing {
-                    let cursor_x = self.base.x + 8.0 + (self.cursor_idx as f32 * char_width) - self.scroll_x;
+                    let offset = if self.glyph_positions.is_empty() {
+                        self.cursor_idx as f32 * char_width
+                    } else {
+                        self.cursor_x_offset
+                    };
+                    let cursor_x = self.base.x + 8.0 + offset - self.scroll_x;
                     if cursor_x >= self.base.x + 8.0 && cursor_x <= self.base.x + self.base.w - 8.0 {
                         let text_y = crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, top);
                         let cursor_y = text_y + (self.font_size - caret_h) / 2.0;
@@ -1143,9 +1227,11 @@ impl Element for TextBox {
             } else {
                 let caret_h = self.font_size * 1.15;
                 if start != end {
-                    let highlight_x = self.base.x + 8.0 + (start as f32 * char_width) - self.scroll_x;
+                    let h_left_offset = self.glyph_positions.get(start).copied().unwrap_or_else(|| start as f32 * char_width);
+                    let h_right_offset = self.glyph_positions.get(end).copied().unwrap_or_else(|| end as f32 * char_width);
+                    let highlight_x = self.base.x + 8.0 + h_left_offset - self.scroll_x;
                     let h_left = highlight_x.max(self.base.x + 8.0);
-                    let h_right = (highlight_x + ((end - start) as f32 * char_width)).min(self.base.x + self.base.w - 8.0);
+                    let h_right = (self.base.x + 8.0 + h_right_offset - self.scroll_x).min(self.base.x + self.base.w - 8.0);
                     if h_left < h_right {
                         quads.push((
                             h_left,
@@ -1160,7 +1246,12 @@ impl Element for TextBox {
                 }
 
                 if self.editing {
-                    let cursor_x = self.base.x + 8.0 + (self.cursor_idx as f32 * char_width) - self.scroll_x;
+                    let offset = if self.glyph_positions.is_empty() {
+                        self.cursor_idx as f32 * char_width
+                    } else {
+                        self.cursor_x_offset
+                    };
+                    let cursor_x = self.base.x + 8.0 + offset - self.scroll_x;
                     if cursor_x >= self.base.x + 8.0 && cursor_x <= self.base.x + self.base.w - 8.0 {
                         let text_y = crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, top);
                         let cursor_y = text_y + (self.font_size - caret_h) / 2.0;