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

commit823af6f966d5afb27daaa89a550ad2752206eaa9
parentff2d3d3083
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-03 04:06
feat(line_edit): undo and redo, grouped the way TextBox groups them

LineEdit keeps a History<Snapshot> (text, caret, selection) of what
handle_key changes, grouped as TextBox does: a typed run is one step and
whitespace starts the next, so undo walks back a word at a time; a run
of Backspace or of Delete is one step; replacing a selection, a paste, a
cut, a Ctrl word-delete and Ctrl+U are each their own; a caret move by
key or click ends a run. undo() / redo() restore the caret and selection
with the text, and a new edit after an undo drops the redo branch.

They are methods, not keys: the runner routes the DE's undo/redo chords
(input.kdl) to Application::undo/redo, and the app calls them on the
focused field. A masked field keeps no history -- past versions of a
password held in memory buy nothing.

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

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

diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
index 21434ac..9f19c3a 100644
--- a/src/widget/line_edit.rs
+++ b/src/widget/line_edit.rs
@@ -21,6 +21,7 @@
 
 use std::time::{Duration, Instant};
 
+use crate::history::History;
 use crate::widget::{ElementState, Key, KeyEvent, NamedKey};
 
 /// How close two presses at the same offset must be to count as one double
@@ -55,6 +56,18 @@ pub struct LineEdit {
     /// The last press — when, where, and how many presses in a row landed
     /// there — for telling a double or triple click from two clicks.
     clicks: Option<(Instant, usize, u8)>,
+    /// Undo and redo for what [`LineEdit::handle_key`] changes. Never kept
+    /// for a masked field (see [`LineEdit::undo`]).
+    history: History<Snapshot>,
+}
+
+/// What an undo step restores: the text, and where the caret and selection
+/// were, so undoing a deletion puts the caret back where it was.
+#[derive(Clone, Debug)]
+struct Snapshot {
+    text: String,
+    cursor: usize,
+    selection: Option<(usize, usize)>,
 }
 
 /// A press being dragged: the span the press selected (empty for a single
@@ -152,6 +165,8 @@ impl LineEdit {
     }
 
     fn press_at(&mut self, at: usize, extend: bool, now: Instant) {
+        // A click moves the caret: typing after it is a new undo step.
+        self.history.break_group();
         let at = self.boundary(at);
         let count = match self.clicks {
             Some((t, p, n)) if !extend && p == at && now.duration_since(t) < DOUBLE_CLICK => n % 3 + 1,
@@ -356,7 +371,91 @@ impl LineEdit {
         }
     }
 
+    /// One key. Edits are recorded for [`LineEdit::undo`] the way `TextBox`
+    /// records them: a typed run is one step and whitespace starts the next,
+    /// so undo walks back a word at a time; a run of Backspace (or Delete)
+    /// is one step; replacing a selection, a paste, a cut, a Ctrl word-delete
+    /// 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.
     pub fn handle_key(&mut self, event: &KeyEvent) -> EditOutcome {
+        let before = (!self.masked).then(|| self.snapshot());
+        let outcome = self.apply_key(event);
+        if let Some(before) = before {
+            if self.text != before.text {
+                let replaced = before.selection.is_some_and(|(a, b)| a < b);
+                let group = match &event.logical_key {
+                    _ if replaced || event.ctrl => None,
+                    Key::Named(NamedKey::Backspace) => Some(2),
+                    Key::Named(NamedKey::Delete) => Some(3),
+                    Key::Named(NamedKey::Space) => Some(4),
+                    _ => {
+                        let ws = event.text.as_deref().is_some_and(|t| t.chars().all(char::is_whitespace));
+                        Some(if ws { 4 } else { 1 })
+                    }
+                };
+                match group {
+                    Some(g) => self.history.record_grouped(before, g),
+                    None => self.history.record(before),
+                }
+            } else if outcome == EditOutcome::Edited {
+                // A caret or selection move between keystrokes splits the
+                // run: "abc", Left, "d" undoes as two steps.
+                self.history.break_group();
+            }
+        }
+        outcome
+    }
+
+    /// Step back to before the last edit; true when there was one. The caret
+    /// and selection come back with the text. A masked field keeps no
+    /// history — holding past versions of a password in memory is a cost
+    /// with nothing to show for it — so this is always false there.
+    pub fn undo(&mut self) -> bool {
+        let current = self.snapshot();
+        let Some(prev) = self.history.undo(current) else {
+            return false;
+        };
+        self.restore(prev);
+        true
+    }
+
+    /// Step forward again after [`LineEdit::undo`]; true when there was a
+    /// step to redo. Any new edit after an undo drops what could be redone.
+    pub fn redo(&mut self) -> bool {
+        let current = self.snapshot();
+        let Some(next) = self.history.redo(current) else {
+            return false;
+        };
+        self.restore(next);
+        true
+    }
+
+    pub fn can_undo(&self) -> bool {
+        self.history.can_undo()
+    }
+
+    pub fn can_redo(&self) -> bool {
+        self.history.can_redo()
+    }
+
+    fn snapshot(&self) -> Snapshot {
+        Snapshot { text: self.text.clone(), cursor: self.cursor, selection: self.selection }
+    }
+
+    fn restore(&mut self, s: Snapshot) {
+        self.text = s.text;
+        self.cursor = s.cursor;
+        self.selection = s.selection;
+        self.drag = None;
+        self.clicks = None;
+    }
+
+    fn apply_key(&mut self, event: &KeyEvent) -> EditOutcome {
         if event.state != ElementState::Pressed {
             return EditOutcome::Ignored;
         }
@@ -844,6 +943,88 @@ mod tests {
         assert_eq!((e.text.as_str(), e.cursor), ("rd", 0), "no stop at the space");
     }
 
+    #[test]
+    fn undo_walks_back_a_word_at_a_time_and_redo_forward() {
+        let mut e = LineEdit::default();
+        typed(&mut e, "hello world");
+        assert!(e.can_undo());
+        assert!(e.undo());
+        assert_eq!((e.text.as_str(), e.cursor), ("hello ", 6), "the word after the space");
+        assert!(e.undo());
+        assert_eq!(e.text, "hello", "then the space");
+        assert!(e.undo());
+        assert_eq!((e.text.as_str(), e.cursor), ("", 0));
+        assert!(!e.undo(), "nothing left");
+        assert!(e.redo());
+        assert!(e.redo());
+        assert!(e.redo());
+        assert_eq!((e.text.as_str(), e.cursor), ("hello world", 11));
+        assert!(!e.redo());
+    }
+
+    #[test]
+    fn a_caret_move_or_click_splits_a_run() {
+        let mut e = LineEdit::default();
+        typed(&mut e, "abc");
+        e.handle_key(&named(NamedKey::ArrowLeft));
+        typed(&mut e, "X");
+        e.undo();
+        assert_eq!((e.text.as_str(), e.cursor), ("abc", 2), "X alone, caret back before c");
+        let mut e = LineEdit::default();
+        typed(&mut e, "abc");
+        e.press(1, false);
+        e.release();
+        typed(&mut e, "X");
+        e.undo();
+        assert_eq!(e.text, "abc");
+    }
+
+    #[test]
+    fn deleting_runs_and_replacements_are_their_own_steps() {
+        let mut e = LineEdit::with_text("hello world");
+        for _ in 0..3 {
+            e.handle_key(&named(NamedKey::Backspace));
+        }
+        assert_eq!(e.text, "hello wo");
+        e.undo();
+        assert_eq!(e.text, "hello world", "three Backspaces, one step");
+        // Typing over a selection: the replacement is one step, then the
+        // rest of the typed run another.
+        e.select_all();
+        typed(&mut e, "xyz");
+        e.undo();
+        assert_eq!(e.text, "x");
+        e.undo();
+        assert_eq!((e.text.as_str(), e.selection), ("hello world", Some((0, 11))), "the selection comes back too");
+        // Ctrl word-delete is a step of its own.
+        e.selection = None;
+        e.handle_key(&ctrl_key(NamedKey::Backspace, false));
+        e.handle_key(&ctrl_key(NamedKey::Backspace, false));
+        assert_eq!(e.text, "");
+        e.undo();
+        assert_eq!(e.text, "hello ");
+    }
+
+    #[test]
+    fn a_new_edit_after_undo_drops_the_redo() {
+        let mut e = LineEdit::default();
+        typed(&mut e, "one two");
+        e.undo();
+        typed(&mut e, "six");
+        assert!(!e.can_redo());
+        assert!(!e.redo());
+        assert_eq!(e.text, "one six");
+    }
+
+    #[test]
+    fn a_masked_field_keeps_no_history() {
+        let mut e = LineEdit::masked();
+        typed(&mut e, "hunter2");
+        assert!(!e.can_undo());
+        assert!(!e.undo());
+        assert_eq!(e.text, "hunter2");
+    }
+
     fn ms(t0: Instant, n: u64) -> Instant {
         t0 + Duration::from_millis(n)
     }