implement undo/redo history tracking

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent 58c66d50f2
commit 246bc3e6e4
3 changed files with 213 additions and 18 deletions
+1 -1
View File
@@ -1,5 +1,6 @@
pub mod actions;
mod element;
mod history;
pub mod notify;
mod state;
mod storage;
@@ -13,7 +14,6 @@ pub use storage::*;
- auto-scroll when cursor moves
- cursor blinking
- color styling configs
- undo/redo
- text sanitation
- test IME (char palette only available on macos)
- unit tests
@@ -0,0 +1,156 @@
use smallvec::SmallVec;
use std::{
ops::Range,
time::{Duration, Instant},
};
/// Maximum number of history entries to keep.
pub const MAX_HISTORY_LEN: usize = 1000;
/// Default interval for grouping consecutive edits into a single undo entry.
pub const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
// TODO: Should history get attached directly to storage? currently its per text field and operate both on storage and selection
pub struct EditableTextHistory {
/// The maximum duration between changes to `content` that can be grouped together as a single entry in the history log.
grouping_interval: Duration,
/// Stack of previous states for undo.
undo_stack: SmallVec<[HistoryEntry; MAX_HISTORY_LEN]>,
/// Stack of undone states for redo.
redo_stack: SmallVec<[HistoryEntry; MAX_HISTORY_LEN]>,
}
impl Default for EditableTextHistory {
fn default() -> Self {
Self {
grouping_interval: DEFAULT_GROUP_INTERVAL,
undo_stack: Default::default(),
redo_stack: Default::default(),
}
}
}
/// A patch-based history entry for memory-efficient undo/redo operations.
/// Instead of storing the full content, we store only the change needed to reverse the edit.
#[derive(Clone, Debug)]
pub struct HistoryEntry {
/// The byte range that was modified (after the edit, for undo; before the edit, for redo).
pub range: Range<usize>,
/// The text that was replaced (to restore on undo).
pub old_text: String,
/// The length of the new text that replaced old_text (to know how much to remove on undo).
pub new_text_len: usize,
/// The selection range before the edit.
pub selected_range: Range<usize>,
/// Timestamp for grouping consecutive edits.
pub timestamp: Instant,
}
#[derive(Clone, Debug)]
pub enum HistoryKind {
Undo,
Redo,
}
impl EditableTextHistory {
pub fn set_grouping_interval(&mut self, interval: Duration) {
self.grouping_interval = interval;
}
pub fn record(
&mut self,
range: Range<usize>,
old_text: &str,
new_text_len: usize,
selected_range: Range<usize>,
) {
let now = Instant::now();
// Check if we should group with the last entry
if let Some(last) = self.undo_stack.last_mut() {
if now.duration_since(last.timestamp) < self.grouping_interval {
// Within group interval - extend the existing patch
if last.extend(&range, new_text_len) {
return;
}
}
}
// Limit history size
if self.undo_stack.len() >= MAX_HISTORY_LEN {
self.undo_stack.remove(0);
}
self.push(
HistoryKind::Undo,
HistoryEntry {
range: range.start..range.start + new_text_len,
old_text: old_text.to_string(),
new_text_len,
selected_range,
timestamp: now,
},
);
// New edit invalidates redo stack
self.redo_stack.clear();
}
fn stack(&self, kind: HistoryKind) -> &SmallVec<[HistoryEntry; MAX_HISTORY_LEN]> {
// NOTE: Could be an internal map
match kind {
HistoryKind::Undo => &self.undo_stack,
HistoryKind::Redo => &self.redo_stack,
}
}
fn stack_mut(&mut self, kind: HistoryKind) -> &mut SmallVec<[HistoryEntry; MAX_HISTORY_LEN]> {
match kind {
HistoryKind::Undo => &mut self.undo_stack,
HistoryKind::Redo => &mut self.redo_stack,
}
}
pub fn has_next(&self, kind: HistoryKind) -> bool {
!self.stack(kind).is_empty()
}
pub fn push(&mut self, kind: HistoryKind, entry: HistoryEntry) {
self.stack_mut(kind).push(entry);
}
pub fn take(&mut self, kind: HistoryKind) -> Option<HistoryEntry> {
self.stack_mut(kind).pop()
}
}
impl HistoryEntry {
fn extend(&mut self, range: &Range<usize>, new_text_len: usize) -> bool {
// NOTE: Could be more robust. Currently only supports human-written extensions from start towards end.
// ranges must be contiguous in order to integrate/extend
if self.range.end != range.start {
return false;
}
self.range.end = range.start + new_text_len;
self.new_text_len += new_text_len;
true
}
pub fn char_range(&self, max_len: usize) -> Range<usize> {
let undo_start = self.range.start;
let undo_end = (self.range.start + self.new_text_len).min(max_len);
undo_start..undo_end
}
pub fn as_inverted(self, prev_text_at_range: String) -> Self {
HistoryEntry {
range: self.range.start..self.range.start + self.old_text.len(),
old_text: prev_text_at_range,
new_text_len: self.old_text.len(),
selected_range: self.selected_range.clone(),
timestamp: self.timestamp,
}
}
}
+56 -17
View File
@@ -1,6 +1,7 @@
use crate::editable_text::{
TextBoundary, UnicodeTextStorage,
actions::EditableTextActionHandler,
history::EditableTextHistory,
notify::{TextChanged, TextHistoryPushed},
};
use gpui::{
@@ -31,6 +32,7 @@ pub struct EditableTextState {
click_count: usize,
focus_handle: FocusHandle,
history: Option<EditableTextHistory>,
pub(super) layout_data: TextInputLayoutData,
}
@@ -94,6 +96,8 @@ impl EditableTextState {
click_count: 0,
focus_handle: cx.focus_handle(),
// TODO: what is the best way to give users access to configure this via element
history: Some(EditableTextHistory::default()),
layout_data: TextInputLayoutData::default(),
}
@@ -187,20 +191,11 @@ impl EditableTextState {
self.selected_range = new_caret..new_caret;
}
fn emit_change_for_undo(&self, cx: &mut Context<Self>, range: Range<usize>, length: usize) {
cx.emit(TextHistoryPushed::new(
range.clone(),
length,
&*self.storage,
self.selected_range.clone(),
));
}
pub fn replace_text_in_range_bytes(
&mut self,
range: Range<usize>,
mut text_to_insert: &str,
cx: &mut Context<Self>,
_cx: &mut Context<Self>,
) {
// TODO: Apply text sanitization
// single-line fields should prune \n and \r
@@ -218,7 +213,7 @@ impl EditableTextState {
let end_pos = range.start + text_to_insert.len();
self.emit_change_for_undo(cx, range.clone(), text_to_insert.len());
self.record_history(range.clone(), text_to_insert.len());
self.storage.replace_range(range, text_to_insert);
self.selected_range = end_pos..end_pos;
self.marked_range = None;
@@ -287,7 +282,7 @@ impl EditableTextState {
.range_from_caret(self.caret_pos(), direction, boundary),
};
self.emit_change_for_undo(cx, range.clone(), 0);
self.record_history(range.clone(), 0);
self.replace_text(&range, "");
self.marked_range = None;
@@ -408,6 +403,50 @@ impl EditableTextState {
}
}
impl EditableTextState {
pub fn history(&self) -> Option<&EditableTextHistory> {
self.history.as_ref()
}
fn record_history(&mut self, range: Range<usize>, new_text_len: usize) {
// Don't record during IME composition
if self.marked_range.is_some() {
return;
}
let Some(history) = &mut self.history else {
return;
};
// Capture the text that will be replaced
let old_text = &self.storage.content_utf8()[range.clone()];
history.record(range, old_text, new_text_len, self.selected_range.clone());
}
fn apply_from_history(&mut self, src: HistoryKind, dst: HistoryKind, cx: &mut Context<Self>) {
let Some(history) = &mut self.history else {
return;
};
let Some(entry) = history.take(src) else {
return;
};
let range = entry.char_range(self.storage.content_utf8().len());
// Snapshot the sub-slice that is being replaced
let removed_text = self.storage.content_utf8()[range.clone()].to_string();
// Replace the slice with the history value
self.storage.replace_range(range, &entry.old_text);
self.selected_range = entry.selected_range.clone();
// Push the entry onto the redo stack so the undo can be undone
history.push(dst, entry.as_inverted(removed_text));
self.scroll_to_caret();
cx.notify();
}
}
impl EntityInputHandler for EditableTextState {
fn text_for_range(
&mut self,
@@ -547,7 +586,7 @@ impl EntityInputHandler for EditableTextState {
}
}
use super::actions::*;
use super::{actions::*, history::HistoryKind};
impl<'app> EditableTextActionHandler<Context<'app, Self>> for EditableTextState {
fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context<'app, Self>) {
self.set_selected_range(0..0);
@@ -814,12 +853,12 @@ impl<'app> EditableTextActionHandler<Context<'app, Self>> for EditableTextState
cx.notify();
}
fn undo(&mut self, _: &Undo, _w: &mut Window, _cx: &mut Context<'app, Self>) {
// TODO: STUB
fn undo(&mut self, _: &Undo, _w: &mut Window, cx: &mut Context<'app, Self>) {
self.apply_from_history(HistoryKind::Undo, HistoryKind::Redo, cx);
}
fn redo(&mut self, _: &Redo, _w: &mut Window, _cx: &mut Context<'app, Self>) {
// TODO: STUB
fn redo(&mut self, _: &Redo, _w: &mut Window, cx: &mut Context<'app, Self>) {
self.apply_from_history(HistoryKind::Redo, HistoryKind::Undo, cx);
}
fn on_mouse_down(