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

commitcbe8b7f46aaeaad05b483fe2fa16f5eed5306dca
parentea414d1342
authorLucas Galante <[email protected]>
date2026-09-03 15:00
feat: undo/redo — History<T>, runner routing, TextBox edit history

The toolkit defines the stack and the routing, never the step.

history.rs: `History<T>`, a snapshot stack over the caller's own state
type with the three rules every undo needs — fork on new edit, one
entry per gesture (`begin_gesture`/`commit_gesture` records lazily so a
drag that never moves leaves nothing; `record_grouped` coalesces typed
runs until `break_group`), and a cap.

Routing lives in the runner: a press matching the `undo`/`redo` chord
(input.kdl, cce-ui domain defaults ctrl+z / ctrl+shift+z, resolved once
at startup) goes to the focused widget as `ContextAction::Undo`/`Redo`
via the new `UiContext::focused_context_action`, then to the new
`Application::undo`/`redo` hooks (default false); only when both decline
does the key reach `handle_key_input`, so an app with its own scheme is
undisturbed. Repeats route too — holding the chord walks the history.

TextBox keeps a per-editing-session history: a typed word is one step,
whitespace starts the next, a cursor move splits a run, Backspace/Delete
runs coalesce, cut/paste/selection-replacement/set_value-while-editing
are their own steps. Only while editing — a committed value is the app's
to undo. The chords are also matched inside handle_key as a fallback for
apps that hand keys to widgets without exposing a UiContext. "Undo" /
"Redo" context-menu rows map to the same actions.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SjC6ZXH9Z31WwLCLMjyBcd

 CLAUDE.md                    |   9 ++
 src/backend/window_runner.rs |  59 +++++++++-
 src/context.rs               |  16 +++
 src/history.rs               | 257 +++++++++++++++++++++++++++++++++++++++++++
 src/lib.rs                   |   1 +
 src/widget/core.rs           |   2 +
 src/widget/input/text_box.rs | 200 ++++++++++++++++++++++++++++++++-
 src/widget/mod.rs            |   6 +
 8 files changed, 543 insertions(+), 7 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 06c2fa8..6b49624 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -72,6 +72,13 @@ Key methods (see the trait def around `window_runner.rs:1450`):
   handler requests a redraw; the loop is demand-driven and idles when nothing sets it.
 - `ui_context()` / `ui_context_mut()` expose the widget tree (`UiContext`) for apps built on the
   retained widget system rather than immediate drawing.
+- **Undo/redo**: the runner owns the routing. A press matching the `undo` / `redo` chord
+  (`input.kdl`, cce-ui domain defaults `ctrl+z` / `ctrl+shift+z`) goes to the focused widget
+  as `ContextAction::Undo` / `Redo` (a TextBox that is editing steps its own typing), then to
+  the app's `undo(needs_rebuild)` / `redo(needs_rebuild)` hooks (default false); only if both
+  decline does the key reach `handle_key_input`. Apps keep their own document history on
+  `cce_ui::history::History<T>` — snapshots of the app's state type, with gesture/group
+  coalescing and the fork-on-new-edit rule built in (module doc in `src/history.rs`).
 
 The frame loop is demand-driven (single `redraw` dirty bool, gated by a Wayland frame-callback
 vsync) — it idles correctly when nothing changes. Don't add per-frame I/O to the render hot path.
@@ -135,6 +142,8 @@ cce-system-interface) to confirm behavior, not just the test suite.
 - `config.rs` — KDL loading and `kdl_to_json` conversion (see workspace `CLAUDE.md` for paths).
 - `context.rs` — `UiContext`: the retained widget tree, event routing, spatial grid, dirty
   tracking, hit-testing.
+- `history.rs` — `History<T>`: the undo/redo snapshot stack (cap, gestures, grouped runs).
+  The toolkit defines the stack and the routing, never the step — see the trait section.
 - `widget/` — `container/` (vbox/hbox/scroll/menu/treelist/…), `input/` (button/slider/text_box/
   dropdown/…), `display/` (label/graph/svg/…), plus `editor.rs`, `json_layout.rs` (KDL/JSON-driven
   layouts), `core.rs`.
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 6c95679..2a3c1f5 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3264,6 +3264,21 @@ pub trait Application: Sized + 'static {
         false
     }
     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message>;
+
+    /// Undo, after the focused widget declined the chord (a text box that is
+    /// editing takes it for its own typing). Return true when something was
+    /// undone; false lets the key fall through to `handle_key_input` like any
+    /// other. The chords are `undo` / `redo` in `input.kdl` (cce-ui domain
+    /// defaults `ctrl+z` / `ctrl+shift+z`), resolved once at startup. Build
+    /// the history on `cce_ui::history::History`.
+    fn undo(&mut self, _needs_rebuild: &mut bool) -> bool {
+        false
+    }
+
+    /// Redo — see [`undo`](Self::undo).
+    fn redo(&mut self, _needs_rebuild: &mut bool) -> bool {
+        false
+    }
     /// Keyboard focus entered/left the window (the compositor keyboard-focuses
     /// the focused window, so this is the "am I the focused window" signal —
     /// e.g. for focus-dependent chrome). Default: ignore.
@@ -3483,6 +3498,9 @@ pub struct EngineState<A: Application> {
     pub extent_gate_skips: u32,
     pub first_configure_received: bool,
     pub ctrl_pressed: bool,
+    /// The `undo` / `redo` chords, resolved from `input.kdl` at startup.
+    pub undo_chord: String,
+    pub redo_chord: String,
     pub shift_pressed: bool,
     pub alt_pressed: bool,
     pub logo_pressed: bool,
@@ -4763,6 +4781,37 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
 }
 
 impl<A: Application> EngineState<A> {
+    /// The toolkit-wide undo/redo routing: a press matching the `undo` /
+    /// `redo` chord goes to the focused widget first (`ContextAction::Undo`
+    /// / `Redo` — a text box that is editing steps its own typing), then to
+    /// the app's `Application::undo` / `redo`. Returns whether either took
+    /// it; otherwise the key is dispatched as usual, so an app with its own
+    /// scheme is undisturbed. Runs for repeats too — holding the chord walks
+    /// the history like holding Backspace walks the text.
+    fn route_history_chord(&mut self, event: &KeyEvent, rebuild: &mut bool) -> bool {
+        if event.state != ElementState::Pressed {
+            return false;
+        }
+        let undo = crate::widget::match_key_shortcut(event, &self.undo_chord);
+        let redo = !undo && crate::widget::match_key_shortcut(event, &self.redo_chord);
+        if !undo && !redo {
+            return false;
+        }
+        let app = self.inner.as_mut().unwrap();
+        let action = if undo { crate::widget::ContextAction::Undo } else { crate::widget::ContextAction::Redo };
+        if let Some(ctx) = app.ui_context_mut() {
+            if ctx.focused_context_action(action) {
+                *rebuild = true;
+                return true;
+            }
+        }
+        let taken = if undo { app.undo(rebuild) } else { app.redo(rebuild) };
+        if taken {
+            *rebuild = true;
+        }
+        taken
+    }
+
     fn handle_key(&mut self, event: smithay_client_toolkit::seat::keyboard::KeyEvent, state: ElementState) {
         let logical_key = match event.keysym {
             xkeysym::Keysym::Escape => Key::Named(NamedKey::Escape),
@@ -4858,6 +4907,10 @@ impl<A: Application> EngineState<A> {
         }
 
         let mut rebuild = false;
+        if self.route_history_chord(&custom_event, &mut rebuild) {
+            self.redraw = true;
+            return;
+        }
         if let Some(msg) = self.inner.as_mut().unwrap().handle_key_input(&custom_event, &mut rebuild) {
             let mut update_rebuild = false;
             self.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut self.exit);
@@ -5328,6 +5381,8 @@ fn run_session<'l, A: Application>(
         extent_gate_skips: 0,
         first_configure_received: false,
         ctrl_pressed: false,
+        undo_chord: crate::input::app_chord("undo", "ctrl+z"),
+        redo_chord: crate::input::app_chord("redo", "ctrl+shift+z"),
         shift_pressed: false,
         alt_pressed: false,
         logo_pressed: false,
@@ -5660,7 +5715,9 @@ fn run_session<'l, A: Application>(
                     }
 
                     let mut key_rebuild = false;
-                    if let Some(msg) = engine_state.inner.as_mut().unwrap().handle_key_input(&custom_event, &mut key_rebuild) {
+                    if engine_state.route_history_chord(&custom_event, &mut key_rebuild) {
+                        engine_state.redraw = true;
+                    } else if let Some(msg) = engine_state.inner.as_mut().unwrap().handle_key_input(&custom_event, &mut key_rebuild) {
                         let mut update_rebuild = false;
                         engine_state.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut engine_state.exit);
                         if update_rebuild {
diff --git a/src/context.rs b/src/context.rs
index 126a76d..5731499 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -518,6 +518,22 @@ impl UiContext {
         self.focused_widget == Some(id)
     }
 
+    /// Offer a context action to the focused widget — the runner's first stop
+    /// for the `undo` / `redo` chords. Returns whether the widget applied it;
+    /// a widget that did is marked dirty.
+    pub fn focused_context_action(&mut self, action: crate::widget::ContextAction) -> bool {
+        let Some(ptr) = self.focused_widget.and_then(|id| self.tree.get_ptr(id)) else {
+            return false;
+        };
+        unsafe {
+            if (*ptr).context_action(action) {
+                (*ptr).mark_dirty(self);
+                return true;
+            }
+        }
+        false
+    }
+
     pub fn clear_focus(&mut self) {
         if let Some(id) = self.focused_widget.take() {
             if let Some(ptr) = self.tree.get_ptr(id) {
diff --git a/src/history.rs b/src/history.rs
new file mode 100644
index 0000000..727e7db
--- /dev/null
+++ b/src/history.rs
@@ -0,0 +1,257 @@
+//! Undo/redo history: a snapshot stack any editing state can own.
+//!
+//! The toolkit deliberately does not define what an undoable step *is* —
+//! that differs per app (a node graph, a palette, a KDL tree, a text
+//! buffer). It defines the stack: `History<T>` holds snapshots of the
+//! caller's own state type, one per recorded step, with the three rules
+//! every undo system needs and every hand-rolled one gets subtly wrong:
+//!
+//! - **fork on new edit** — recording after an undo drops the redo branch;
+//! - **one entry per gesture** — a drag or a typed run is one step, via
+//!   [`History::begin_gesture`] (records lazily, so a gesture that never
+//!   changes anything leaves nothing) and [`History::record_grouped`]
+//!   (consecutive records in the same group keep only the first snapshot);
+//! - **a cap** — the oldest entries fall off.
+//!
+//! Routing is the other half of the system and lives in the runner: the
+//! `undo` / `redo` chords from `input.kdl` (cce-ui domain defaults
+//! `ctrl+z` / `ctrl+shift+z`) go first to the focused widget
+//! ([`ContextAction::Undo`](crate::widget::ContextAction) — a text box
+//! undoes its own typing), then to the app's
+//! [`Application::undo`](crate::engine::Application::undo) /
+//! [`redo`](crate::engine::Application::redo). An app that owns a
+//! project-wide history answers there; an app with several editing states
+//! consults them in order and answers with the first that has something.
+//!
+//! Snapshots, not commands: the state types in this DE are small and
+//! cloneable, and a snapshot restore is correct no matter what happened to
+//! the state in between (an MCP edit, a reload) — a command's inverse is
+//! not. Apps whose state is large can hold a diff type in `T` instead; the
+//! stack does not care.
+
+/// Default cap on undo depth.
+pub const DEFAULT_LIMIT: usize = 256;
+
+#[derive(Debug, Clone)]
+pub struct History<T> {
+    undo: Vec<T>,
+    redo: Vec<T>,
+    limit: usize,
+    /// A gesture's pre-state, held until the first change commits it.
+    pending: Option<T>,
+    /// The group of the last record, for coalescing typed runs.
+    last_group: Option<u32>,
+}
+
+impl<T> Default for History<T> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl<T> History<T> {
+    pub fn new() -> Self {
+        Self::with_limit(DEFAULT_LIMIT)
+    }
+
+    pub fn with_limit(limit: usize) -> Self {
+        History { undo: Vec::new(), redo: Vec::new(), limit: limit.max(1), pending: None, last_group: None }
+    }
+
+    /// Record `before` as the state the next undo returns to. Forks: the
+    /// redo branch is dropped. Ends any coalescing group.
+    pub fn record(&mut self, before: T) {
+        self.last_group = None;
+        self.push(before);
+    }
+
+    /// Record `before` unless the previous record was in the same `group`,
+    /// in which case the earlier snapshot already covers this change and
+    /// nothing is pushed. Typing "hello" with group per keystroke is one
+    /// step; call [`break_group`](Self::break_group) when something else
+    /// happens between two keystrokes (a cursor move) so the next one starts
+    /// a fresh step.
+    pub fn record_grouped(&mut self, before: T, group: u32) {
+        if self.last_group == Some(group) && !self.undo.is_empty() {
+            // Still one step. A redo branch cannot exist here: an undo
+            // breaks the group, so a fresh record after it forks as usual.
+            return;
+        }
+        self.push(before);
+        self.last_group = Some(group);
+    }
+
+    /// End the current coalescing group: the next grouped record starts a
+    /// new step even if it is in the same group.
+    pub fn break_group(&mut self) {
+        self.last_group = None;
+    }
+
+    /// Start a gesture (a drag): hold `before` without recording it. The
+    /// first [`commit_gesture`](Self::commit_gesture) records it; a gesture
+    /// that ends without one leaves no history entry.
+    pub fn begin_gesture(&mut self, before: T) {
+        self.pending = Some(before);
+    }
+
+    /// The gesture changed something: record its pre-state, once. Returns
+    /// whether this call was the one that recorded it.
+    pub fn commit_gesture(&mut self) -> bool {
+        match self.pending.take() {
+            Some(before) => {
+                self.record(before);
+                true
+            }
+            None => false,
+        }
+    }
+
+    /// Drop a gesture that changed nothing (or was abandoned).
+    pub fn cancel_gesture(&mut self) {
+        self.pending = None;
+    }
+
+    pub fn in_gesture(&self) -> bool {
+        self.pending.is_some()
+    }
+
+    /// Step back: returns the snapshot to restore, having filed `current`
+    /// on the redo stack. `None` when there is nothing to undo — `current`
+    /// is dropped in that case, so callers can pass a fresh clone.
+    pub fn undo(&mut self, current: T) -> Option<T> {
+        let target = self.undo.pop()?;
+        self.redo.push(current);
+        self.pending = None;
+        self.last_group = None;
+        Some(target)
+    }
+
+    /// Step forward: the counterpart of [`undo`](Self::undo).
+    pub fn redo(&mut self, current: T) -> Option<T> {
+        let target = self.redo.pop()?;
+        self.undo.push(current);
+        self.pending = None;
+        self.last_group = None;
+        Some(target)
+    }
+
+    pub fn can_undo(&self) -> bool {
+        !self.undo.is_empty()
+    }
+
+    pub fn can_redo(&self) -> bool {
+        !self.redo.is_empty()
+    }
+
+    pub fn undo_len(&self) -> usize {
+        self.undo.len()
+    }
+
+    pub fn redo_len(&self) -> usize {
+        self.redo.len()
+    }
+
+    /// Forget everything — a new document, a new editing session.
+    pub fn clear(&mut self) {
+        self.undo.clear();
+        self.redo.clear();
+        self.pending = None;
+        self.last_group = None;
+    }
+
+    fn push(&mut self, before: T) {
+        self.undo.push(before);
+        self.redo.clear();
+        if self.undo.len() > self.limit {
+            let excess = self.undo.len() - self.limit;
+            self.undo.drain(..excess);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn undo_redo_walk_and_fork() {
+        let mut h = History::new();
+        let mut v = 0;
+        for next in 1..=3 {
+            h.record(v);
+            v = next;
+        }
+        assert_eq!((h.undo_len(), h.redo_len()), (3, 0));
+        v = h.undo(v).unwrap();
+        assert_eq!(v, 2);
+        v = h.undo(v).unwrap();
+        assert_eq!(v, 1);
+        assert_eq!((h.undo_len(), h.redo_len()), (1, 2));
+        v = h.redo(v).unwrap();
+        assert_eq!(v, 2);
+        // A new edit after an undo drops the remaining redo branch.
+        h.record(v);
+        v = 10;
+        assert_eq!((h.undo_len(), h.redo_len()), (3, 0));
+        assert!(h.redo(v).is_none());
+        v = h.undo(v).unwrap();
+        assert_eq!(v, 2);
+        assert_eq!(h.redo(v), Some(10));
+    }
+
+    #[test]
+    fn gesture_records_once_and_only_if_committed() {
+        let mut h: History<i32> = History::new();
+        h.begin_gesture(0);
+        assert!(h.in_gesture());
+        h.cancel_gesture();
+        assert!(!h.can_undo(), "an abandoned gesture leaves nothing");
+
+        h.begin_gesture(0);
+        assert!(h.commit_gesture());
+        assert!(!h.commit_gesture(), "second motion is the same step");
+        assert!(!h.commit_gesture());
+        assert_eq!(h.undo_len(), 1);
+        assert_eq!(h.undo(5), Some(0));
+    }
+
+    #[test]
+    fn grouped_records_coalesce_until_broken() {
+        let mut h: History<&str> = History::new();
+        h.record_grouped("", 1);
+        h.record_grouped("h", 1);
+        h.record_grouped("he", 1);
+        assert_eq!(h.undo_len(), 1, "a typed run is one step");
+        h.record_grouped("hel", 2);
+        assert_eq!(h.undo_len(), 2, "a different group starts a step");
+        h.break_group();
+        h.record_grouped("hel ", 2);
+        assert_eq!(h.undo_len(), 3, "break_group splits the same group");
+        assert_eq!(h.undo("hel w"), Some("hel "));
+        // An undo ends the group too: the next grouped record is a fresh
+        // step (and forks the redo branch).
+        h.record_grouped("hel ", 2);
+        assert_eq!((h.undo_len(), h.redo_len()), (3, 0));
+    }
+
+    #[test]
+    fn limit_drops_the_oldest() {
+        let mut h = History::with_limit(2);
+        h.record(1);
+        h.record(2);
+        h.record(3);
+        assert_eq!(h.undo_len(), 2);
+        assert_eq!(h.undo(4), Some(3));
+        assert_eq!(h.undo(3), Some(2));
+        assert_eq!(h.undo(2), None);
+    }
+
+    #[test]
+    fn clear_forgets_everything() {
+        let mut h = History::new();
+        h.record(1);
+        h.begin_gesture(2);
+        h.clear();
+        assert!(!h.can_undo() && !h.can_redo() && !h.in_gesture());
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 7fdac0f..2791354 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,6 +2,7 @@ pub mod color;
 pub mod widget;
 pub mod config;
 pub mod input;
+pub mod history;
 pub mod layout;
 pub mod relief_spec;
 pub mod wayland;
diff --git a/src/widget/core.rs b/src/widget/core.rs
index d376c1f..7c9655a 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -443,6 +443,8 @@ pub mod context_menu {
                                         "Copy" => Some(CA::Copy),
                                         "Paste" => Some(CA::Paste),
                                         "Select All" => Some(CA::SelectAll),
+                                        "Undo" => Some(CA::Undo),
+                                        "Redo" => Some(CA::Redo),
                                         "Cear" => Some(CA::ClearText),
                                         "Copy Key" => Some(CA::CopyKey),
                                         "Copy Value" => Some(CA::CopyValue),
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index bc875e1..f977d69 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -22,6 +22,7 @@ use crate::widget::*;
 use crate::scene::layout::{Rect, Size};
 use crate::scene::paint::PaintCtx;
 use crate::widget::model::{Adapted, EventCtx, Input, Layout, Paint};
+use crate::history::History;
 use std::sync::OnceLock;
 
 static FONT_DB: OnceLock<resvg::usvg::fontdb::Database> = OnceLock::new();
@@ -72,6 +73,12 @@ pub struct TextBox {
     pub font_family: String,
     pub placeholder: Option<String>,
     pub editor_state: TextEditorState,
+    /// Edit history for the current editing session (cleared by
+    /// `begin_editing`): a typed run is one step, a deleted run one, a
+    /// paste/cut/selection-replacement one. Stepped by `ContextAction::Undo`
+    /// / `Redo`, which the runner routes here on the `undo` / `redo` chords
+    /// while the box is focused and editing.
+    pub history: History<TextEditorState>,
     pub scroll_y: f32,
     pub scroll_x: f32,
     default_font_size: f32,
@@ -133,6 +140,7 @@ impl TextBox {
             font_family: style_family.clone(),
             placeholder: None,
             editor_state,
+            history: History::new(),
             scroll_y: 0.0,
             scroll_x: 0.0,
             default_font_size: style_size,
@@ -379,6 +387,58 @@ impl TextBox {
         };
     }
 
+    /// The editing fields as one value — what the history stores.
+    fn snapshot(&self) -> TextEditorState {
+        TextEditorState {
+            buffer: self.edit_buffer.clone(),
+            cursor_idx: self.cursor_idx,
+            select_anchor: self.select_anchor,
+            all_selected: self.all_selected,
+        }
+    }
+
+    fn restore(&mut self, snap: TextEditorState) {
+        self.edit_buffer = snap.buffer;
+        self.cursor_idx = snap.cursor_idx;
+        self.select_anchor = snap.select_anchor;
+        self.all_selected = snap.all_selected;
+        self.sync_editor_state();
+        self.scroll_to_cursor();
+    }
+
+    /// Step the edit buffer back one recorded step. Only while editing —
+    /// a committed value is the app's to undo, not the box's.
+    pub fn undo_edit(&mut self) -> bool {
+        if !self.editing || self.disabled {
+            return false;
+        }
+        let current = self.snapshot();
+        match self.history.undo(current) {
+            Some(prev) => {
+                self.restore(prev);
+                self.just_changed = true;
+                true
+            }
+            None => false,
+        }
+    }
+
+    /// Step forward again — see [`undo_edit`](Self::undo_edit).
+    pub fn redo_edit(&mut self) -> bool {
+        if !self.editing || self.disabled {
+            return false;
+        }
+        let current = self.snapshot();
+        match self.history.redo(current) {
+            Some(next) => {
+                self.restore(next);
+                self.just_changed = true;
+                true
+            }
+            None => false,
+        }
+    }
+
     pub fn copy_selection(&self) {
         let state = TextEditorState {
             buffer: self.edit_buffer.clone(),
@@ -392,6 +452,7 @@ impl TextBox {
     }
 
     pub fn cut_selection(&mut self) -> bool {
+        let before = self.snapshot();
         let mut state = TextEditorState {
             buffer: std::mem::take(&mut self.edit_buffer),
             cursor_idx: self.cursor_idx,
@@ -401,6 +462,7 @@ impl TextBox {
         if let Some(text) = state.selected_text() {
             clipboard::copy_to_clipboard(&text);
             state.insert_text("");
+            self.history.record(before);
             self.edit_buffer = state.buffer;
             self.cursor_idx = state.cursor_idx;
             self.select_anchor = state.select_anchor;
@@ -416,6 +478,7 @@ impl TextBox {
 
     pub fn paste_from_clipboard(&mut self) -> bool {
         if let Some(text) = clipboard::read_from_clipboard() {
+            let before = self.snapshot();
             let mut state = TextEditorState {
                 buffer: std::mem::take(&mut self.edit_buffer),
                 cursor_idx: self.cursor_idx,
@@ -429,6 +492,7 @@ impl TextBox {
                 }
             }
             state.insert_text(&cleaned);
+            self.history.record(before);
             self.edit_buffer = state.buffer;
             self.cursor_idx = state.cursor_idx;
             self.select_anchor = state.select_anchor;
@@ -458,6 +522,12 @@ impl TextBox {
     pub fn set_value(&mut self, val: &str) -> bool {
         let val_str = val.to_string();
         if self.text != val_str {
+            if self.editing {
+                let before = self.snapshot();
+                self.history.record(before);
+            } else {
+                self.history.clear();
+            }
             self.text = val_str.clone();
             self.edit_buffer = val_str;
             self.just_changed = true;
@@ -593,6 +663,7 @@ impl TextBox {
         self.select_anchor = Some(0);
         self.all_selected = len > 0;
         self.just_focused = true;
+        self.history.clear();
         self.sync_editor_state();
     }
 
@@ -657,12 +728,21 @@ impl TextBox {
 
         let control = event.ctrl;
 
-        let mut state = TextEditorState {
-            buffer: self.edit_buffer.clone(),
-            cursor_idx: self.cursor_idx,
-            select_anchor: self.select_anchor,
-            all_selected: self.all_selected,
-        };
+        // The undo/redo chords, for apps that hand keys to widgets without
+        // exposing a `UiContext` (the runner's routing reaches the box
+        // through `ContextAction` first when they do). Before the working
+        // copy below, since a step replaces the whole editing state.
+        if control {
+            if match_key_shortcut(event, &crate::input::widget_chord("undo", "", "ctrl+z")) {
+                return self.undo_edit();
+            }
+            if match_key_shortcut(event, &crate::input::widget_chord("redo", "", "ctrl+shift+z")) {
+                return self.redo_edit();
+            }
+        }
+
+        let before = self.snapshot();
+        let mut state = before.clone();
 
         let handled = match &event.logical_key {
             Key::Named(NamedKey::Backspace) => {
@@ -794,6 +874,29 @@ impl TextBox {
         };
 
         if self.editing {
+            if state.buffer != before.buffer {
+                // One step per typed run, per deleted run; whitespace
+                // starts a new run so undo walks back a word at a time.
+                // Replacing a selection is always its own step.
+                let group = match &event.logical_key {
+                    _ if before.selected_range().is_some() => None,
+                    Key::Named(NamedKey::Backspace) => Some(2),
+                    Key::Named(NamedKey::Delete) => Some(3),
+                    Key::Character(_) if !control => {
+                        let ws = event.text.as_deref().map_or(false, |t| t.chars().all(char::is_whitespace));
+                        Some(if ws { 4 } else { 1 })
+                    }
+                    _ => None,
+                };
+                match group {
+                    Some(g) => self.history.record_grouped(before, g),
+                    None => self.history.record(before),
+                }
+            } else if handled {
+                // A cursor or selection move between keystrokes splits the
+                // run: "abc", move, "def" undoes as two steps.
+                self.history.break_group();
+            }
             self.edit_buffer = state.buffer;
             self.cursor_idx = state.cursor_idx;
             self.select_anchor = state.select_anchor;
@@ -1646,6 +1749,8 @@ impl Input for TextBox {
                 self.set_value("");
                 true
             }
+            CA::Undo => self.undo_edit(),
+            CA::Redo => self.redo_edit(),
             _ => false,
         }
     }
@@ -1845,6 +1950,89 @@ mod tests {
         assert!(tb.edit_buffer.starts_with("imap"), "the existing value survives the first keystroke");
     }
 
+    /// Undo/redo over an editing session: a typed word is one step, a
+    /// space starts the next, a cursor move splits a run, Backspace runs
+    /// coalesce, redo walks forward, a fresh keystroke after an undo forks,
+    /// and the chord reaches the box both as a `ContextAction` (the runner's
+    /// route) and as a raw key (the no-context fallback).
+    #[test]
+    fn typing_undoes_by_run() {
+        let mut dummy = crate::context::UiContext::new();
+        let mut tb = TextBox::new(String::new());
+        tb.set_rect(10.0, 10.0, 300.0, 30.0);
+        tb.focus();
+        assert!(tb.editing);
+        assert!(!tb.undo_edit(), "a fresh session has nothing to undo");
+
+        let key = |k: &str, ctrl: bool, shift: bool| KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Character(k.to_string()),
+            text: if ctrl { None } else { Some(k.to_string()) },
+            repeat: false,
+            ctrl,
+            shift,
+            alt: false,
+        };
+        let named = |n: NamedKey| KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(n),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+            alt: false,
+        };
+        let type_str = |tb: &mut Adapted<TextBox>, dummy: &mut crate::context::UiContext, s: &str| {
+            for ch in s.chars() {
+                assert!(tb.keyboard_input(&key(&ch.to_string(), false, false), dummy));
+            }
+        };
+
+        type_str(&mut tb, &mut dummy, "hello world");
+        assert_eq!(tb.edit_buffer, "hello world");
+        assert_eq!(tb.history.undo_len(), 3, "'hello', ' ', 'world'");
+
+        // A cursor move splits the next run off.
+        assert!(tb.keyboard_input(&named(NamedKey::ArrowLeft), &mut dummy));
+        type_str(&mut tb, &mut dummy, "XY");
+        assert_eq!(tb.edit_buffer, "hello worlXYd");
+        assert_eq!(tb.history.undo_len(), 4);
+
+        // Backspaces coalesce into one step.
+        assert!(tb.keyboard_input(&named(NamedKey::Backspace), &mut dummy));
+        assert!(tb.keyboard_input(&named(NamedKey::Backspace), &mut dummy));
+        assert_eq!(tb.edit_buffer, "hello world");
+        assert_eq!(tb.history.undo_len(), 5);
+
+        // Undo through the ContextAction route, then the raw-chord route.
+        assert!(WidgetHost::context_action(&mut tb, crate::widget::ContextAction::Undo));
+        assert_eq!(tb.edit_buffer, "hello worlXYd");
+        assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
+        assert_eq!(tb.edit_buffer, "hello world");
+        assert!(tb.keyboard_input(&key("Z", true, true), &mut dummy), "redo via ctrl+shift+z");
+        assert_eq!(tb.edit_buffer, "hello worlXYd");
+        assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
+        assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
+        assert_eq!(tb.edit_buffer, "hello ");
+        assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
+        assert_eq!(tb.edit_buffer, "hello");
+        assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
+        assert_eq!(tb.edit_buffer, "");
+        assert!(!tb.undo_edit(), "history exhausted");
+
+        // Redo forward one, then a fresh keystroke forks the branch.
+        assert!(WidgetHost::context_action(&mut tb, crate::widget::ContextAction::Redo));
+        assert_eq!(tb.edit_buffer, "hello");
+        type_str(&mut tb, &mut dummy, "!");
+        assert_eq!(tb.edit_buffer, "hello!");
+        assert!(!tb.redo_edit(), "a new edit after an undo drops the redo branch");
+
+        // A committed value is not the box's to undo.
+        tb.unfocus();
+        assert!(!tb.editing);
+        assert!(!WidgetHost::context_action(&mut tb, crate::widget::ContextAction::Undo));
+    }
+
     #[test]
     fn empty_box_click_ignores_placeholder_glyphs() {
         let mut dummy = crate::context::UiContext::new();
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index d5dcbee..094a28d 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -103,6 +103,12 @@ pub enum ContextAction {
     Copy,
     Paste,
     SelectAll,
+    /// Step the widget's own edit history (a text box's typing). Routed by
+    /// the runner to the focused widget on the `undo` / `redo` chords before
+    /// the app's `Application::undo` / `redo` get their turn; also reachable
+    /// as "Undo" / "Redo" context-menu rows.
+    Undo,
+    Redo,
     ClearText,
     CopyKey,
     CopyValue,