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

commit91de54e288ea2178df526acf4607559667b2128e
parent34bdb08d8e
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 15:32
feat(line_edit): select by dragging the pointer, and shift+click

press(at, extend) puts the caret at a byte offset the app hit-tested
and starts a drag (extend keeps the selection's far end: shift+click);
drag_to(at) selects from the press to the pointer, in either direction,
and says whether anything changed; release() ends it, keeping what was
selected. A dragged selection is edited like any other. Offsets are
clamped into the text and onto a char boundary, and text_index maps an
offset in a masked field's bullets back to the text's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/widget/line_edit.rs | 162 +++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 161 insertions(+), 1 deletion(-)

diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
index 0ae3488..b22e1d0 100644
--- a/src/widget/line_edit.rs
+++ b/src/widget/line_edit.rs
@@ -6,7 +6,9 @@
 //! draws [`LineEdit::display`] with the caret and selection wherever its
 //! own layout puts them. Enter and Escape come back as [`EditOutcome`]s for
 //! the caller to act on, as does any chord the field does not own, so the
-//! app's own shortcuts still reach it. cce-browser's URL bar and its dialog
+//! app's own shortcuts still reach it. The pointer selects by dragging:
+//! the app hit-tests a press and each motion to a byte offset and calls
+//! [`LineEdit::press`] / [`LineEdit::drag_to`] / [`LineEdit::release`]. cce-browser's URL bar and its dialog
 //! fields (HTTP auth, JS prompts) are built on it.
 //!
 //! Indices are **byte** offsets into `text`, always on a char boundary
@@ -40,6 +42,9 @@ pub struct LineEdit {
     pub selection: Option<(usize, usize)>,
     /// Render as bullets. Set for password fields.
     pub masked: bool,
+    /// While a pointer button is held after [`LineEdit::press`]: the byte the
+    /// selection runs from. `None` when no drag is in progress.
+    drag_anchor: Option<usize>,
 }
 
 /// The char boundary before byte `i` in `s` (0 at the start). For stepping a
@@ -91,6 +96,85 @@ impl LineEdit {
         self.selection = (self.cursor > 0).then_some((0, self.cursor));
     }
 
+    // ---- the pointer ----
+    //
+    // The field does not know where its characters are drawn; the app does.
+    // So the pointer arrives as a byte offset into `text` that the app has
+    // already hit-tested (nearest char boundary to the pointer's x on the
+    // same shaped run it drew), and for a masked field converted with
+    // `text_index`, since what was drawn there is bullets.
+
+    /// A press at byte `at`: the caret goes there and a drag starts from it.
+    /// With `extend` (Shift held) the selection's far end stays put and the
+    /// selection runs from it to `at` instead — shift+click.
+    pub fn press(&mut self, at: usize, extend: bool) {
+        let at = self.boundary(at);
+        let anchor = if extend { self.anchor() } else { at };
+        self.drag_anchor = Some(anchor);
+        self.select_between(anchor, at);
+    }
+
+    /// The pointer moved to byte `at` with the button still held: the
+    /// selection is now everything between the press and here, and the caret
+    /// is here. True when that changed anything (a repaint is due); false,
+    /// and nothing happens, when no press is in progress.
+    pub fn drag_to(&mut self, at: usize) -> bool {
+        let Some(anchor) = self.drag_anchor else {
+            return false;
+        };
+        let before = (self.cursor, self.selection);
+        self.select_between(anchor, self.boundary(at));
+        (self.cursor, self.selection) != before
+    }
+
+    /// The button came up: the drag is over, and what it selected stays
+    /// selected.
+    pub fn release(&mut self) {
+        self.drag_anchor = None;
+    }
+
+    /// Whether a press is being dragged — the app routes pointer motion to
+    /// [`LineEdit::drag_to`] while this holds, wherever the pointer is.
+    pub fn dragging(&self) -> bool {
+        self.drag_anchor.is_some()
+    }
+
+    /// A byte offset into [`LineEdit::display`] as the offset into `text` it
+    /// stands for: the same offset unless the field is masked, where each
+    /// bullet stands for one character of the text.
+    pub fn text_index(&self, display_at: usize) -> usize {
+        if !self.masked {
+            return self.boundary(display_at);
+        }
+        let n = display_at / '\u{2022}'.len_utf8();
+        self.text.char_indices().nth(n).map_or(self.text.len(), |(i, _)| i)
+    }
+
+    /// The end of the selection the caret is not at — what a shift+click
+    /// keeps — or the caret itself with nothing selected.
+    fn anchor(&self) -> usize {
+        match self.selection {
+            Some((a, b)) if self.cursor == a => b,
+            Some((a, _)) => a,
+            None => self.cursor,
+        }
+    }
+
+    fn select_between(&mut self, anchor: usize, at: usize) {
+        self.cursor = at;
+        self.selection = (anchor != at).then(|| (anchor.min(at), anchor.max(at)));
+    }
+
+    /// `at` clamped into the text and onto a char boundary.
+    fn boundary(&self, at: usize) -> usize {
+        let at = at.min(self.text.len());
+        if self.text.is_char_boundary(at) {
+            at
+        } else {
+            prev_boundary(&self.text, at)
+        }
+    }
+
     fn take_selection(&mut self) -> bool {
         match self.selection.take() {
             Some((a, b)) if a < b && b <= self.text.len() => {
@@ -302,6 +386,82 @@ mod tests {
         assert_eq!(e.text, "1");
     }
 
+    #[test]
+    fn a_drag_selects_from_the_press_to_the_pointer_either_way() {
+        let mut e = LineEdit::with_text("hello world");
+        e.press(2, false);
+        assert!(e.dragging());
+        assert_eq!((e.cursor, e.selection), (2, None), "a press alone selects nothing");
+        assert!(e.drag_to(7));
+        assert_eq!((e.cursor, e.selection), (7, Some((2, 7))));
+        assert!(!e.drag_to(7), "no move, no repaint");
+        // Back past the press: the selection flips to the other side of it.
+        e.drag_to(0);
+        assert_eq!((e.cursor, e.selection), (0, Some((0, 2))));
+        // Back onto the press point: nothing selected, caret there.
+        e.drag_to(2);
+        assert_eq!((e.cursor, e.selection), (2, None));
+        e.drag_to(11);
+        e.release();
+        assert!(!e.dragging());
+        assert_eq!(e.selection, Some((2, 11)), "release keeps what was dragged");
+        assert!(!e.drag_to(4), "motion after release is not a drag");
+        assert_eq!(e.selection, Some((2, 11)));
+    }
+
+    #[test]
+    fn shift_click_extends_from_the_far_end() {
+        let mut e = LineEdit::with_text("hello world");
+        e.press(3, false);
+        e.release();
+        e.press(8, true);
+        assert_eq!((e.cursor, e.selection), (8, Some((3, 8))));
+        e.release();
+        // Shift+click on the other side of the anchor keeps the anchor.
+        e.press(1, true);
+        assert_eq!((e.cursor, e.selection), (1, Some((1, 3))));
+        e.release();
+        // After a select-all the caret is at the end, so the start is kept.
+        e.select_all();
+        e.press(5, true);
+        assert_eq!(e.selection, Some((0, 5)));
+    }
+
+    #[test]
+    fn a_dragged_selection_is_edited_like_any_other() {
+        let mut e = LineEdit::with_text("hello world");
+        e.press(0, false);
+        e.drag_to(6);
+        e.release();
+        typed(&mut e, "big ");
+        assert_eq!(e.text, "big world");
+        assert_eq!(e.cursor, 4);
+    }
+
+    /// The app's offset is clamped into the text and never splits a char.
+    #[test]
+    fn pointer_offsets_land_on_char_boundaries() {
+        let mut e = LineEdit::with_text("aé");
+        e.press(2, false); // inside é's two bytes
+        assert_eq!(e.cursor, 1);
+        e.drag_to(99);
+        assert_eq!((e.cursor, e.selection), (3, Some((1, 3))));
+    }
+
+    /// Each bullet is three bytes of display and one char of text.
+    #[test]
+    fn a_masked_field_maps_bullets_back_to_the_text() {
+        let mut e = LineEdit::masked();
+        typed(&mut e, "pé!");
+        let bullet = '\u{2022}'.len_utf8();
+        assert_eq!(e.text_index(0), 0);
+        assert_eq!(e.text_index(bullet), 1);
+        assert_eq!(e.text_index(2 * bullet), 3, "past é's two bytes");
+        assert_eq!(e.text_index(3 * bullet), 4);
+        assert_eq!(e.text_index(99), 4);
+        assert_eq!(LineEdit::with_text("abc").text_index(2), 2, "unmasked is the identity");
+    }
+
     /// A password must not leave through a chord the user may not have meant.
     #[test]
     fn a_masked_field_hides_its_text_and_refuses_copy() {