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

commitad50deb3b2b6b9bfd9a7a3ed408c9fa36e5d1d0f
parentaf6a22802b
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-03 03:57
feat(line_edit): Ctrl+arrows jump a word, Ctrl+Shift+arrows select one

word_left / word_right: back (or on) over anything that is not a word
character, then to the far edge of the word there -- the same word
characters as a double-click (letters, digits, `_`), so in a URL
Ctrl+Right stops at the ends of https, example, com. Plain, a jump leaves
from the selection's edge in its direction and drops the selection;
with Shift the selection follows the caret from its anchor, as Shift+arrow
does. On a masked field the jumps go to the ends, for the reason a
double-click there selects everything.

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

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

diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
index 819a703..f5051c0 100644
--- a/src/widget/line_edit.rs
+++ b/src/widget/line_edit.rs
@@ -74,6 +74,12 @@ enum Unit {
     All,
 }
 
+/// A word character for double-click and Ctrl+arrow purposes: letters and
+/// digits, and `_` (as `DocEditor` counts them).
+fn is_word_char(c: char) -> bool {
+    c.is_alphanumeric() || c == '_'
+}
+
 /// The char boundary before byte `i` in `s` (0 at the start). For stepping a
 /// caret, or trimming text to fit without splitting a character.
 pub fn prev_boundary(s: &str, i: usize) -> usize {
@@ -214,11 +220,53 @@ impl LineEdit {
     /// counts them), spaces, or anything else. So in a URL `example` is a
     /// word and so is `://`. On the edge of a word the word wins, so a click
     /// just past a word's last letter still selects the word.
+    /// Where Ctrl+Left goes from byte `at`: back over anything that is not
+    /// a word character, then to the start of the word before it — the same
+    /// word characters as [`LineEdit::word_at`] (letters, digits, `_`). On a
+    /// masked field, straight to the start: stopping at word edges would show
+    /// where a password's spaces and symbols are.
+    pub fn word_left(&self, at: usize) -> usize {
+        if self.masked {
+            return 0;
+        }
+        let s = &self.text;
+        let mut i = self.boundary(at);
+        for want_word in [false, true] {
+            while let Some(c) = s[..i].chars().next_back() {
+                if is_word_char(c) != want_word {
+                    break;
+                }
+                i -= c.len_utf8();
+            }
+        }
+        i
+    }
+
+    /// Where Ctrl+Right goes from byte `at`: forward over anything that is
+    /// not a word character, then to the end of the word after it. On a
+    /// masked field, straight to the end (see [`LineEdit::word_left`]).
+    pub fn word_right(&self, at: usize) -> usize {
+        if self.masked {
+            return self.text.len();
+        }
+        let s = &self.text;
+        let mut i = self.boundary(at);
+        for want_word in [false, true] {
+            while let Some(c) = s[i..].chars().next() {
+                if is_word_char(c) != want_word {
+                    break;
+                }
+                i += c.len_utf8();
+            }
+        }
+        i
+    }
+
     pub fn word_at(&self, at: usize) -> (usize, usize) {
         let at = self.boundary(at);
         let s = &self.text;
         let kind = |c: char| {
-            if c.is_alphanumeric() || c == '_' {
+            if is_word_char(c) {
                 0
             } else if c.is_whitespace() {
                 1
@@ -335,6 +383,27 @@ impl LineEdit {
             // its anchor — the end the caret is not at, the same one a
             // shift+click keeps — so it grows, shrinks, or flips past the
             // anchor as the caret goes.
+            // Ctrl moves by word, plain or with Shift. Plain, it starts from
+            // the selection's edge in the direction it goes, as a plain
+            // arrow collapses to that edge.
+            Key::Named(NamedKey::ArrowLeft) if event.ctrl => {
+                if event.shift {
+                    let to = self.word_left(self.cursor);
+                    self.select_between(self.anchor(), to);
+                } else {
+                    let from = self.selection.take().map_or(self.cursor, |(a, _)| a);
+                    self.cursor = self.word_left(from);
+                }
+            }
+            Key::Named(NamedKey::ArrowRight) if event.ctrl => {
+                if event.shift {
+                    let to = self.word_right(self.cursor);
+                    self.select_between(self.anchor(), to);
+                } else {
+                    let from = self.selection.take().map_or(self.cursor, |(_, b)| b);
+                    self.cursor = self.word_right(from);
+                }
+            }
             Key::Named(NamedKey::ArrowLeft) if event.shift => {
                 let to = prev_boundary(&self.text, self.cursor);
                 self.select_between(self.anchor(), to);
@@ -660,6 +729,59 @@ mod tests {
         assert_eq!((e.cursor, e.selection), (1, Some((1, 3))));
     }
 
+    fn ctrl_key(n: NamedKey, shift: bool) -> KeyEvent {
+        KeyEvent { ctrl: true, shift, ..named(n) }
+    }
+
+    #[test]
+    fn ctrl_arrows_jump_by_word() {
+        let mut e = LineEdit::with_text("https://example.com/a_b  c");
+        e.handle_key(&named(NamedKey::Home));
+        let mut stops = Vec::new();
+        for _ in 0..6 {
+            e.handle_key(&ctrl_key(NamedKey::ArrowRight, false));
+            stops.push(e.cursor);
+        }
+        assert_eq!(stops, [5, 15, 19, 23, 26, 26], "ends of https, example, com, a_b, c; then stays");
+        let mut back = Vec::new();
+        for _ in 0..6 {
+            e.handle_key(&ctrl_key(NamedKey::ArrowLeft, false));
+            back.push(e.cursor);
+        }
+        assert_eq!(back, [25, 20, 16, 8, 0, 0], "starts of c, a_b, com, example, https; then stays");
+        // From inside a word: to that word's own edge.
+        e.cursor = 11;
+        e.handle_key(&ctrl_key(NamedKey::ArrowLeft, false));
+        assert_eq!(e.cursor, 8);
+    }
+
+    #[test]
+    fn ctrl_shift_arrows_select_by_word() {
+        let mut e = LineEdit::with_text("one two three");
+        e.handle_key(&named(NamedKey::Home));
+        e.handle_key(&ctrl_key(NamedKey::ArrowRight, true));
+        e.handle_key(&ctrl_key(NamedKey::ArrowRight, true));
+        assert_eq!((e.cursor, e.selection), (7, Some((0, 7))));
+        e.handle_key(&ctrl_key(NamedKey::ArrowLeft, true));
+        assert_eq!((e.cursor, e.selection), (4, Some((0, 4))), "shrinks a word back");
+        // A plain Ctrl+arrow leaves from the selection's edge and drops it.
+        e.handle_key(&ctrl_key(NamedKey::ArrowRight, false));
+        assert_eq!((e.cursor, e.selection), (7, None));
+        e.select_all();
+        e.handle_key(&ctrl_key(NamedKey::ArrowLeft, false));
+        assert_eq!((e.cursor, e.selection), (0, None));
+    }
+
+    #[test]
+    fn ctrl_arrows_in_a_password_go_to_the_ends() {
+        let mut e = LineEdit::masked();
+        typed(&mut e, "pass word");
+        e.handle_key(&ctrl_key(NamedKey::ArrowLeft, false));
+        assert_eq!(e.cursor, 0, "no stop at the space");
+        e.handle_key(&ctrl_key(NamedKey::ArrowRight, true));
+        assert_eq!(e.selection, Some((0, 9)));
+    }
+
     fn ms(t0: Instant, n: u64) -> Instant {
         t0 + Duration::from_millis(n)
     }