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

commitd59ab4222daa721e6762d00450d723777ba6df97
parentfdbd3bfbe0
authorLucas Galante <[email protected]>
date2026-09-03 12:08
text_box: clamp click-to-index to the real text, not the shaped placeholder

When the box is empty, prepare_text shapes the placeholder into
glyph_positions, so a focusing click snapped the caret to the nearest
placeholder column — visibly parking it mid/end of "Search..." in
cce-data-editor's tree search box. Clamp map_x_to_idx to the current
value's length (the unshaped fallback already did), so an empty box
always places the caret at 0.

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

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

diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index e3a39f3..bc875e1 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -183,7 +183,12 @@ impl TextBox {
                 closest_idx = i;
             }
         }
-        closest_idx
+        // When the box is empty, `prepare_text` shapes the PLACEHOLDER into
+        // `glyph_positions`, so the nearest-glyph snap above can land on a
+        // placeholder column. Clamp to the real text: the placeholder is
+        // painted, not caret-addressable.
+        let text_len = if self.editing { self.edit_buffer.chars().count() } else { self.text.chars().count() };
+        closest_idx.min(text_len)
     }
 
     /// The x offset of `col` on wrapped line `line`, from the shaped per-line
@@ -1840,6 +1845,25 @@ mod tests {
         assert!(tb.edit_buffer.starts_with("imap"), "the existing value survives the first keystroke");
     }
 
+    #[test]
+    fn empty_box_click_ignores_placeholder_glyphs() {
+        let mut dummy = crate::context::UiContext::new();
+        let mut tb = TextBox::new(String::new()).with_placeholder("Search...");
+        tb.set_rect(10.0, 10.0, 200.0, 30.0);
+
+        // `prepare_text` shapes the placeholder when the value is empty; the
+        // caret math must still treat the box as zero-length. Simulate the
+        // shaped placeholder ("Search...", 9 cols) directly so the test does
+        // not depend on a font being present.
+        tb.glyph_positions = (0..=9).map(|i| i as f32 * 7.0).collect();
+
+        // Click deep into the painted placeholder.
+        let click_x = 10.0 + 8.0 + 8.0 * 7.0;
+        assert!(tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x, 20.0, &mut dummy));
+        assert!(tb.editing);
+        assert_eq!(tb.cursor_idx, 0, "empty box: the caret lands at the start, not on a placeholder column");
+    }
+
     #[test]
     fn multiline_focus_click_places_caret_instead_of_select_all() {
         let mut dummy = crate::context::UiContext::new();