Files
galaxy/crates/editor/src/content/undo.rs
T

343 lines
11 KiB
Rust

use std::{collections::VecDeque, time::Duration};
use galaxy_util::content_version::ContentVersion;
use instant::Instant;
use crate::render::model::RenderedSelectionSet;
use super::core::{CoreEditorAction, ReplacementRange};
/// Threshold to separate two non-atomic undo items.
const UNDO_REDO_TIMER: Duration = Duration::from_millis(500);
/// Represents one high-level editor action that should be
/// undo/redo in one step. The high-level action could be broken
/// down into a set of smaller core editor actions.
///
/// Note that we also record the selection and replacement range state
/// before and after the editor action since they don't need to be
/// recalculated.
#[derive(Debug, Clone)]
pub struct ReversibleEditorActions {
pub actions: Vec<ReversibleEditorAction>,
pub selections: ReversibleSelectionState,
pub replacement_range: ReplacementRange,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UndoActionType {
// The action is atomic and should not be combined with any other action
// for a batched undo/redo.
Atomic,
// The action is non-atomic and could be undo/redo in a batch.
NonAtomic(NonAtomicType),
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum NonAtomicType {
Insert,
Backspace,
}
impl NonAtomicType {
// Whether the change adds or removes content.
fn is_addition(&self) -> bool {
match self {
NonAtomicType::Insert => true,
NonAtomicType::Backspace => false,
}
}
}
/// A collection of editor actions that should be undo/redo together.
struct UndoStackItem {
items: VecDeque<Vec<ReversibleEditorAction>>,
selections: ReversibleSelectionState,
replacement_range: ReplacementRange,
// The version of the content after the original actions were applied.
version: ContentVersion,
}
impl UndoStackItem {
/// Create a new undo stack item.
/// The version of the buffer after the actions were applied is also recorded.
fn new(item: ReversibleEditorActions, version: ContentVersion) -> Self {
let mut new_items = VecDeque::new();
new_items.push_back(item.actions);
Self {
items: new_items,
selections: item.selections,
replacement_range: item.replacement_range,
version,
}
}
fn reverse(&mut self) {
let mut new_items = VecDeque::new();
// We are reversing three things here:
// 1. The batched edit actions' order
// 2. Within each edit action, its core edit actions' order
// 3. Each core edit action
for mut item in self.items.drain(..) {
item.reverse();
for action in &mut item {
std::mem::swap(&mut action.next, &mut action.reverse);
}
new_items.push_front(item);
}
self.items = new_items;
std::mem::swap(&mut self.selections.next, &mut self.selections.reverse);
std::mem::swap(
&mut self.replacement_range.new_range,
&mut self.replacement_range.old_range,
);
}
// Pushing another edit action to the batch.
fn add(
&mut self,
item: ReversibleEditorActions,
action: NonAtomicType,
version: ContentVersion,
) {
let is_addition = action.is_addition();
self.items.push_front(item.actions);
self.selections.reverse = item.selections.reverse;
self.version = version;
// This might be a bit confusing. For batched additions, the new range after the undo
// will always be the first edit actions' new range. The old range needs to be
// extended because we are inserting content into the buffer in each action.
//
// For batched deletions, the new ranges' start needs to be moved back with each edit because
// we are removing content from the buffer. The old range will be the range of the last edit
// actions' old range.
if is_addition {
self.replacement_range.old_range.end = self
.replacement_range
.old_range
.end
.max(item.replacement_range.old_range.end);
} else {
self.replacement_range.old_range = item.replacement_range.old_range;
self.replacement_range.new_range.start = self
.replacement_range
.new_range
.start
.min(item.replacement_range.new_range.start);
}
}
pub fn actions(&self) -> Vec<Vec<CoreEditorAction>> {
let mut result = Vec::new();
for item in &self.items {
result.push(item.iter().map(|action| action.next.clone()).collect());
}
result
}
pub fn selection(&self) -> RenderedSelectionSet {
self.selections.next.clone()
}
}
#[derive(Debug, Clone)]
pub struct ReversibleEditorAction {
pub next: CoreEditorAction,
pub reverse: CoreEditorAction,
}
#[derive(Debug, Clone)]
pub struct ReversibleSelectionState {
pub next: RenderedSelectionSet,
pub reverse: RenderedSelectionSet,
}
/// Changes need to be applied to the buffer model with a triggered
/// undo/redo action.
#[derive(Debug)]
pub struct UndoResult {
pub actions: Vec<Vec<CoreEditorAction>>,
pub selection: RenderedSelectionSet,
pub replacement_range: ReplacementRange,
}
#[derive(Debug, Clone)]
pub(super) struct UndoArg {
pub actions: Vec<ReversibleEditorAction>,
pub replacement_range: ReplacementRange,
}
pub(super) struct UndoStack {
stack: VecDeque<UndoStackItem>,
current_index: usize,
capacity: usize,
previous_action_type: Option<NonAtomicType>,
last_edit: Option<Instant>,
// The buffer version after everything is undone.
initial_version: ContentVersion,
}
impl UndoStack {
pub fn new(capacity: usize, initial_version: ContentVersion) -> UndoStack {
Self {
stack: Default::default(),
current_index: 0,
capacity,
previous_action_type: None,
last_edit: None,
initial_version,
}
}
pub fn clear_previous_non_atomic_type(&mut self) {
self.previous_action_type = None;
self.last_edit = None;
}
fn push_undo_item_to_stack(&mut self, item: ReversibleEditorActions, version: ContentVersion) {
let mut stack_size = self.stack.len();
if self.current_index < stack_size {
// We need to clear the redo stack if the user has undone already and then make edits.
self.stack.truncate(self.current_index);
stack_size = self.current_index;
}
if stack_size == self.capacity {
self.initial_version = self
.stack
.pop_front()
.expect("Undo stack is empty after checking size")
.version;
self.current_index -= 1;
}
self.stack.push_back(UndoStackItem::new(item, version));
self.current_index += 1;
}
#[cfg(test)]
pub(super) fn push_non_atomic_edit(
&mut self,
item: ReversibleEditorActions,
action: NonAtomicType,
version: ContentVersion,
) {
if self.previous_action_type == Some(action) && self.current_index > 0 {
self.stack[self.current_index - 1].add(item, action, version);
return;
}
self.previous_action_type = Some(action);
self.push_undo_item_to_stack(item, version);
}
pub fn push_new_edit(
&mut self,
item: ReversibleEditorActions,
action: UndoActionType,
version: ContentVersion,
) {
// An item should be pushed into the last batch if:
// 1. Action is non-atomic
// 2. Action matches the previous batch's non-atomic type
// 3. The gap between two edits hasn't exceeded threshold
if let UndoActionType::NonAtomic(action) = action {
let elapsed_time_within_limit = match self.last_edit {
Some(timer) => timer.elapsed() < UNDO_REDO_TIMER,
None => false,
};
if self.previous_action_type == Some(action)
&& self.current_index > 0
&& elapsed_time_within_limit
{
self.stack[self.current_index - 1].add(item, action, version);
return;
}
self.previous_action_type = Some(action);
self.last_edit = Some(Instant::now());
} else {
self.previous_action_type = None;
self.last_edit = None;
}
self.push_undo_item_to_stack(item, version);
}
pub fn undo(&mut self) -> Option<UndoResult> {
if self.stack.is_empty() || self.current_index == 0 {
return None;
}
self.previous_action_type = None;
self.current_index -= 1;
let undo_item = &self.stack[self.current_index];
let undo_result = UndoResult {
actions: undo_item.actions(),
selection: undo_item.selection(),
replacement_range: undo_item.replacement_range.clone(),
};
self.stack[self.current_index].reverse();
Some(undo_result)
}
pub fn redo(&mut self) -> Option<UndoResult> {
if self.current_index >= self.stack.len() {
return None;
}
let redo_item = &self.stack[self.current_index];
let redo_result = UndoResult {
actions: redo_item.actions(),
selection: redo_item.selection(),
replacement_range: redo_item.replacement_range.clone(),
};
self.stack[self.current_index].reverse();
self.current_index += 1;
Some(redo_result)
}
pub fn reset(&mut self, version: ContentVersion) {
self.stack.clear();
self.current_index = 0;
self.clear_previous_non_atomic_type();
self.initial_version = version;
}
/// Check if the given version matches the current version as reflected in the undo stack.
/// If at the bottom of the stack, check if it's equal to the earliest version of the content
/// that the undo stack was aware of.
/// If the stack is not empty, check if the version matches with the last completed item in the stack.
/// Due to undo/redo moving the `current_index` pointer in the stack, the last completed item is not
/// always the last item on the stack.
pub fn version_match(&self, version: &ContentVersion) -> bool {
if self.current_index == 0 {
return *version == self.initial_version;
}
self.stack[self.current_index - 1].version == *version
}
pub(super) fn set_initial_version(&mut self, version: ContentVersion) {
self.initial_version = version;
}
pub(super) fn is_empty(&self) -> bool {
self.stack.is_empty()
}
}
#[cfg(test)]
#[path = "undo_test.rs"]
pub mod tests;