feat(tui/input): add Ctrl+Z undo in composer TextArea

Introduce a simple bounded undo stack (128 snapshots) that records text and
cursor before each mutation (insert/replace). Wire Ctrl+Z to revert the last
edit so pastes and bulk edits can be undone in terminals lacking native Cmd-Z.
No behavioral changes to existing bindings.
This commit is contained in:
James Peter
2025-09-08 11:23:50 +10:00
parent c422d92dfd
commit a589aeeef2
+44
View File
@@ -20,6 +20,9 @@ pub(crate) struct TextArea {
cursor_pos: usize,
wrap_cache: RefCell<Option<WrapCache>>,
preferred_col: Option<usize>,
// Simple undo stack capturing full snapshots of text and cursor before edits.
// This is intentionally simple to reliably undo paste and bulk edits across terminals.
undo_stack: Vec<UndoSnapshot>,
}
#[derive(Debug, Clone)]
@@ -28,6 +31,12 @@ struct WrapCache {
lines: Vec<Range<usize>>,
}
#[derive(Debug, Clone)]
struct UndoSnapshot {
text: String,
cursor_pos: usize,
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct TextAreaState {
/// Index into wrapped lines of the first visible line.
@@ -41,6 +50,7 @@ impl TextArea {
cursor_pos: 0,
wrap_cache: RefCell::new(None),
preferred_col: None,
undo_stack: Vec::new(),
}
}
@@ -60,6 +70,7 @@ impl TextArea {
}
pub fn insert_str_at(&mut self, pos: usize, text: &str) {
self.push_undo_snapshot();
let pos = self.clamp_pos_for_insertion(pos);
self.text.insert_str(pos, text);
self.wrap_cache.replace(None);
@@ -77,6 +88,7 @@ impl TextArea {
fn replace_range_raw(&mut self, range: std::ops::Range<usize>, text: &str) {
assert!(range.start <= range.end);
self.push_undo_snapshot();
let start = range.start.clamp(0, self.text.len());
let end = range.end.clamp(0, self.text.len());
let removed_len = end - start;
@@ -295,6 +307,14 @@ impl TextArea {
} => {
self.delete_backward_word();
}
// Undo: Ctrl+Z reverts the last edit snapshot (paste, insert, delete, etc.).
KeyEvent {
code: KeyCode::Char('z'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.undo();
}
// macOS-like shortcuts (when terminals report the Command key as SUPER):
// Cmd+Left -> move to beginning of line
// Cmd+Right -> move to end of line
@@ -517,6 +537,16 @@ impl TextArea {
self.set_cursor(bol);
}
/// Revert the most recent edit (if any).
pub fn undo(&mut self) {
if let Some(prev) = self.undo_stack.pop() {
self.text = prev.text;
self.cursor_pos = prev.cursor_pos.min(self.text.len());
self.wrap_cache.replace(None);
self.preferred_col = None;
}
}
/// Move the cursor left by a single grapheme cluster.
pub fn move_cursor_left(&mut self) {
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
@@ -729,6 +759,20 @@ impl TextArea {
}
}
fn push_undo_snapshot(&mut self) {
// Capture current state before mutation.
self.undo_stack.push(UndoSnapshot {
text: self.text.clone(),
cursor_pos: self.cursor_pos,
});
// Keep a bounded history to avoid unbounded memory growth.
const MAX_UNDO_SNAPSHOTS: usize = 128;
if self.undo_stack.len() > MAX_UNDO_SNAPSHOTS {
let excess = self.undo_stack.len() - MAX_UNDO_SNAPSHOTS;
self.undo_stack.drain(0..excess);
}
}
pub(crate) fn beginning_of_previous_word(&self) -> usize {
if let Some(first_non_ws) = self.text[..self.cursor_pos].rfind(|c: char| !c.is_whitespace())
{