GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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,