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

commitff2d3d3083a9c75a318c2ff67c1661cce8efa931
parentad50deb3b2
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-03 04:02
feat(line_edit): Ctrl+Backspace and Ctrl+Delete delete a word

Ctrl+Backspace deletes back to where Ctrl+Left would go, Ctrl+Delete on
to where Ctrl+Right would, so in a URL `/drag` then `.com/`... go a word
and its separators at a time. A selection is deleted instead, as with
the plain keys. On a masked field the word edges are the ends, so these
clear to the start or the end of a password.

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

 src/widget/line_edit.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 62 insertions(+)

diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
index f5051c0..21434ac 100644
--- a/src/widget/line_edit.rs
+++ b/src/widget/line_edit.rs
@@ -365,6 +365,23 @@ impl LineEdit {
         match &event.logical_key {
             Key::Named(NamedKey::Enter) => return EditOutcome::Submit,
             Key::Named(NamedKey::Escape) => return EditOutcome::Cancel,
+            // Ctrl deletes a word: back to where Ctrl+Left would go, or on to
+            // where Ctrl+Right would. A selection goes instead, as it does
+            // for the plain keys. On a masked field the word edges are the
+            // ends, so these clear to the start or the end.
+            Key::Named(NamedKey::Backspace) if event.ctrl => {
+                if !self.take_selection() {
+                    let from = self.word_left(self.cursor);
+                    self.text.replace_range(from..self.cursor, "");
+                    self.cursor = from;
+                }
+            }
+            Key::Named(NamedKey::Delete) if event.ctrl => {
+                if !self.take_selection() {
+                    let to = self.word_right(self.cursor);
+                    self.text.replace_range(self.cursor..to, "");
+                }
+            }
             Key::Named(NamedKey::Backspace) => {
                 if !self.take_selection() && self.cursor > 0 {
                     let prev = prev_boundary(&self.text, self.cursor);
@@ -782,6 +799,51 @@ mod tests {
         assert_eq!(e.selection, Some((0, 9)));
     }
 
+    #[test]
+    fn ctrl_backspace_and_delete_take_a_word() {
+        let mut e = LineEdit::with_text("https://example.com/drag");
+        e.handle_key(&ctrl_key(NamedKey::Backspace, false));
+        assert_eq!((e.text.as_str(), e.cursor), ("https://example.com/", 20));
+        e.handle_key(&ctrl_key(NamedKey::Backspace, false));
+        assert_eq!((e.text.as_str(), e.cursor), ("https://example.", 16), "the / and com go together");
+        // From inside a word: just its first half.
+        let mut e = LineEdit::with_text("hello world");
+        e.cursor = 8;
+        e.handle_key(&ctrl_key(NamedKey::Backspace, false));
+        assert_eq!((e.text.as_str(), e.cursor), ("hello rld", 6));
+        // Ctrl+Delete: forward to the end of the word.
+        e.cursor = 0;
+        e.handle_key(&ctrl_key(NamedKey::Delete, false));
+        assert_eq!((e.text.as_str(), e.cursor), (" rld", 0));
+        // At the ends nothing happens.
+        let mut e = LineEdit::with_text("abc");
+        e.handle_key(&ctrl_key(NamedKey::Delete, false));
+        assert_eq!(e.text, "abc");
+        e.cursor = 0;
+        e.handle_key(&ctrl_key(NamedKey::Backspace, false));
+        assert_eq!(e.text, "abc");
+    }
+
+    #[test]
+    fn ctrl_backspace_takes_a_selection_not_a_word() {
+        let mut e = LineEdit::with_text("one two three");
+        e.press(4, false);
+        e.drag_to(6);
+        e.release();
+        e.handle_key(&ctrl_key(NamedKey::Backspace, false));
+        assert_eq!((e.text.as_str(), e.cursor), ("one o three", 4));
+    }
+
+    #[test]
+    fn ctrl_backspace_in_a_password_clears_to_the_start() {
+        let mut e = LineEdit::masked();
+        typed(&mut e, "pass word");
+        e.handle_key(&named(NamedKey::ArrowLeft));
+        e.handle_key(&named(NamedKey::ArrowLeft)); // before "rd"
+        e.handle_key(&ctrl_key(NamedKey::Backspace, false));
+        assert_eq!((e.text.as_str(), e.cursor), ("rd", 0), "no stop at the space");
+    }
+
     fn ms(t0: Instant, n: u64) -> Instant {
         t0 + Duration::from_millis(n)
     }