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

commit32621051c8f1301a905abb0a2f5d7bcf77941e7a
parent823af6f966
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-03 04:10
feat(line_edit): Ctrl+Y redoes

Ctrl+Y is the redo many hands expect, but it is not a DE chord (the
runner routes input.kdl's undo/redo, Ctrl+Z / Ctrl+Shift+Z), so it
arrives at handle_key as a key: it now redoes there, Ignored with
nothing to redo. It is caught before the edit recording, which would
otherwise file the redo as a fresh edit and drop the rest of the redo
branch.

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

 src/widget/line_edit.rs | 42 ++++++++++++++++++++++++++++++++++++++----
 1 file changed, 38 insertions(+), 4 deletions(-)

diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
index 9f19c3a..96ff869 100644
--- a/src/widget/line_edit.rs
+++ b/src/widget/line_edit.rs
@@ -378,11 +378,24 @@ impl LineEdit {
     /// and Ctrl+U are each their own; and a caret move — by key or click —
     /// ends a run.
     ///
-    /// Undo and redo themselves are not keys here: the window runner routes
-    /// the DE's `undo` / `redo` chords (`input.kdl`, Ctrl+Z / Ctrl+Shift+Z
-    /// by default) to the app, which calls [`LineEdit::undo`] /
-    /// [`LineEdit::redo`] on the field that has focus.
+    /// Undo and redo are mostly not keys here: the window runner routes the
+    /// DE's `undo` / `redo` chords (`input.kdl`, Ctrl+Z / Ctrl+Shift+Z by
+    /// default) to the app, which calls [`LineEdit::undo`] /
+    /// [`LineEdit::redo`] on the field that has focus. The one exception is
+    /// **Ctrl+Y**, the other redo hands expect, which is not a DE chord and
+    /// so arrives here as a key: it redoes, or is `Ignored` with nothing to
+    /// redo. (A DE chord bound to Ctrl+Y is offered to the app first, as
+    /// any chord is, and never gets this far.)
     pub fn handle_key(&mut self, event: &KeyEvent) -> EditOutcome {
+        // Before the recording below, which would file the redo as a fresh
+        // edit and so drop everything left to redo.
+        if event.state == ElementState::Pressed
+            && event.ctrl
+            && !event.shift
+            && matches!(&event.logical_key, Key::Character(c) if c.eq_ignore_ascii_case("y"))
+        {
+            return if self.redo() { EditOutcome::Edited } else { EditOutcome::Ignored };
+        }
         let before = (!self.masked).then(|| self.snapshot());
         let outcome = self.apply_key(event);
         if let Some(before) = before {
@@ -1016,6 +1029,27 @@ mod tests {
         assert_eq!(e.text, "one six");
     }
 
+    #[test]
+    fn ctrl_y_redoes_and_keeps_the_rest_redoable() {
+        let mut e = LineEdit::default();
+        typed(&mut e, "one two three");
+        e.undo();
+        e.undo();
+        e.undo();
+        assert_eq!(e.text, "one ");
+        assert_eq!(e.handle_key(&ctrl("y")), EditOutcome::Edited);
+        assert_eq!(e.text, "one two");
+        assert!(e.can_redo(), "a redo by key is not a new edit");
+        e.handle_key(&ctrl("y"));
+        e.handle_key(&ctrl("y"));
+        assert_eq!(e.text, "one two three");
+        assert_eq!(e.handle_key(&ctrl("y")), EditOutcome::Ignored, "nothing left to redo");
+        assert_eq!(e.text, "one two three", "and no stray y typed");
+        // Undo still steps back over what Ctrl+Y redid.
+        e.undo();
+        assert_eq!(e.text, "one two ");
+    }
+
     #[test]
     fn a_masked_field_keeps_no_history() {
         let mut e = LineEdit::masked();