implement undo/redo history tracking
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user