use alloc::{collections::VecDeque, vec::Vec};
use azul_core::{
dom::{DomId, NodeId},
selection::{OptionSelectionRange, OptionTextCursor},
task::Instant,
};
use azul_css::{impl_option, impl_option_inner, AzString};
use super::changeset::TextChangeset;
pub const MAX_UNDO_HISTORY: usize = 10;
pub const MAX_REDO_HISTORY: usize = 10;
#[derive(Debug, Clone)]
#[repr(C)]
pub struct NodeStateSnapshot {
pub node_id: NodeId,
pub text_content: AzString,
pub cursor_position: OptionTextCursor,
pub selection_range: OptionSelectionRange,
pub timestamp: Instant,
}
#[derive(Debug, Clone)]
#[repr(C)]
pub struct UndoableOperation {
pub changeset: TextChangeset,
pub pre_state: NodeStateSnapshot,
}
impl_option!(
UndoableOperation,
OptionUndoableOperation,
copy = false,
[Debug, Clone]
);
#[derive(Debug, Clone)]
pub struct NodeUndoRedoStack {
pub node_id: NodeId,
pub undo_stack: VecDeque<UndoableOperation>,
pub redo_stack: VecDeque<UndoableOperation>,
}
impl NodeUndoRedoStack {
#[must_use] pub fn new(node_id: NodeId) -> Self {
Self {
node_id,
undo_stack: VecDeque::with_capacity(MAX_UNDO_HISTORY),
redo_stack: VecDeque::with_capacity(MAX_REDO_HISTORY),
}
}
pub fn push_undo(&mut self, operation: UndoableOperation) {
self.redo_stack.clear();
self.undo_stack.push_back(operation);
if self.undo_stack.len() > MAX_UNDO_HISTORY {
self.undo_stack.pop_front();
}
}
pub fn push_undo_preserving_redo(&mut self, operation: UndoableOperation) {
self.undo_stack.push_back(operation);
if self.undo_stack.len() > MAX_UNDO_HISTORY {
self.undo_stack.pop_front();
}
}
pub fn pop_undo(&mut self) -> Option<UndoableOperation> {
self.undo_stack.pop_back()
}
pub fn push_redo(&mut self, operation: UndoableOperation) {
self.redo_stack.push_back(operation);
if self.redo_stack.len() > MAX_REDO_HISTORY {
self.redo_stack.pop_front();
}
}
pub fn pop_redo(&mut self) -> Option<UndoableOperation> {
self.redo_stack.pop_back()
}
#[must_use] pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
#[must_use] pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
#[must_use] pub fn peek_undo(&self) -> Option<&UndoableOperation> {
self.undo_stack.back()
}
#[must_use] pub fn peek_redo(&self) -> Option<&UndoableOperation> {
self.redo_stack.back()
}
}
#[derive(Debug, Clone)]
pub struct ContentSnapshot {
pub pre: Vec<crate::text3::cache::InlineContent>,
pub post: Vec<crate::text3::cache::InlineContent>,
}
const MAX_CONTENT_SNAPSHOTS: usize = 64;
#[derive(Debug, Clone, Default)]
pub struct UndoRedoManager {
pub node_stacks: Vec<NodeUndoRedoStack>,
pub content_snapshots: Vec<(super::changeset::ChangesetId, ContentSnapshot)>,
}
impl UndoRedoManager {
#[must_use] pub const fn new() -> Self {
Self {
node_stacks: Vec::new(),
content_snapshots: Vec::new(),
}
}
pub fn store_content_snapshot(
&mut self,
id: super::changeset::ChangesetId,
pre: Vec<crate::text3::cache::InlineContent>,
post: Vec<crate::text3::cache::InlineContent>,
) {
self.content_snapshots.retain(|(existing, _)| *existing != id);
self.content_snapshots.push((id, ContentSnapshot { pre, post }));
if self.content_snapshots.len() > MAX_CONTENT_SNAPSHOTS {
self.content_snapshots.remove(0);
}
}
#[must_use] pub fn get_content_snapshot(
&self,
id: super::changeset::ChangesetId,
) -> Option<&ContentSnapshot> {
self.content_snapshots
.iter()
.find(|(existing, _)| *existing == id)
.map(|(_, snap)| snap)
}
pub fn reinstate_undo(&mut self, operation: UndoableOperation) {
let node_id = operation
.changeset
.target
.node
.into_crate_internal()
.expect("TextChangeset target node should not be None");
let stack = self.get_or_create_stack_mut(node_id);
stack.push_undo_preserving_redo(operation);
}
pub fn get_or_create_stack_mut(&mut self, node_id: NodeId) -> &mut NodeUndoRedoStack {
if let Some(pos) = self.node_stacks.iter().position(|s| s.node_id == node_id) {
&mut self.node_stacks[pos]
} else {
self.node_stacks.push(NodeUndoRedoStack::new(node_id));
self.node_stacks.last_mut().unwrap()
}
}
#[must_use] pub fn get_stack(&self, node_id: NodeId) -> Option<&NodeUndoRedoStack> {
self.node_stacks.iter().find(|s| s.node_id == node_id)
}
fn get_stack_mut(&mut self, node_id: NodeId) -> Option<&mut NodeUndoRedoStack> {
self.node_stacks.iter_mut().find(|s| s.node_id == node_id)
}
pub fn record_operation(&mut self, changeset: TextChangeset, pre_state: NodeStateSnapshot) {
let node_id = changeset
.target
.node
.into_crate_internal()
.expect("TextChangeset target node should not be None");
let stack = self.get_or_create_stack_mut(node_id);
let operation = UndoableOperation {
changeset,
pre_state,
};
stack.push_undo(operation);
}
#[must_use] pub fn can_undo(&self, node_id: NodeId) -> bool {
self.get_stack(node_id)
.is_some_and(NodeUndoRedoStack::can_undo)
}
#[must_use] pub fn can_redo(&self, node_id: NodeId) -> bool {
self.get_stack(node_id)
.is_some_and(NodeUndoRedoStack::can_redo)
}
#[must_use] pub fn peek_undo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
self.get_stack(node_id).and_then(|s| s.peek_undo())
}
#[must_use] pub fn peek_redo(&self, node_id: NodeId) -> Option<&UndoableOperation> {
self.get_stack(node_id).and_then(|s| s.peek_redo())
}
pub fn pop_undo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
self.get_stack_mut(node_id)?.pop_undo()
}
pub fn pop_redo(&mut self, node_id: NodeId) -> Option<UndoableOperation> {
self.get_stack_mut(node_id)?.pop_redo()
}
pub fn push_redo(&mut self, operation: UndoableOperation) {
let node_id = operation
.changeset
.target
.node
.into_crate_internal()
.expect("TextChangeset target node should not be None");
let stack = self.get_or_create_stack_mut(node_id);
stack.push_redo(operation);
}
pub fn push_undo(&mut self, operation: UndoableOperation) {
let node_id = operation
.changeset
.target
.node
.into_crate_internal()
.expect("TextChangeset target node should not be None");
let stack = self.get_or_create_stack_mut(node_id);
stack.push_undo(operation);
}
}
impl crate::managers::NodeIdRemap for UndoRedoManager {
fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
let old_stacks = core::mem::take(&mut self.node_stacks);
for mut stack in old_stacks {
let stack_dom = stack
.undo_stack
.front()
.or_else(|| stack.redo_stack.front())
.map(|op| op.changeset.target.dom);
if stack_dom.is_some_and(|d| d != dom) {
self.node_stacks.push(stack);
continue;
}
let Some(new_node_id) = map.resolve(stack.node_id) else {
continue;
};
stack.node_id = new_node_id;
for op in stack.undo_stack.iter_mut().chain(stack.redo_stack.iter_mut()) {
remap_operation(op, dom, map, new_node_id);
}
self.node_stacks.push(stack);
}
let live: alloc::collections::BTreeSet<_> = self
.node_stacks
.iter()
.flat_map(|s| s.undo_stack.iter().chain(s.redo_stack.iter()))
.map(|op| op.changeset.id)
.collect();
self.content_snapshots.retain(|(id, _)| live.contains(id));
}
}
fn remap_operation(
op: &mut UndoableOperation,
dom: DomId,
map: &crate::managers::NodeIdMap,
new_node_id: NodeId,
) {
use azul_core::styled_dom::NodeHierarchyItemId;
if op.changeset.target.dom == dom {
op.changeset.target.node = NodeHierarchyItemId::from_crate_internal(Some(new_node_id));
}
if let Some(remapped) = map.resolve(op.pre_state.node_id) {
op.pre_state.node_id = remapped;
} else {
op.pre_state.node_id = new_node_id;
}
}
#[cfg(test)]
mod undo_redo_tests {
use super::*;
use crate::managers::changeset::{TextChangeset, TextOpInsertText, TextOperation};
use azul_core::dom::{DomId, DomNodeId};
use azul_core::styled_dom::NodeHierarchyItemId;
use azul_core::task::SystemTick;
use azul_core::window::CursorPosition;
fn ts() -> Instant {
Instant::Tick(SystemTick { tick_counter: 0 })
}
fn op(id: usize, node: usize) -> UndoableOperation {
UndoableOperation {
changeset: TextChangeset {
id,
target: DomNodeId {
dom: DomId { inner: 0 },
node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
},
operation: TextOperation::InsertText(TextOpInsertText {
text: "x".into(),
position: CursorPosition::Uninitialized,
new_cursor: CursorPosition::Uninitialized,
}),
timestamp: ts(),
},
pre_state: NodeStateSnapshot {
node_id: NodeId::new(node),
text_content: "".into(),
cursor_position: None.into(),
selection_range: None.into(),
timestamp: ts(),
},
}
}
#[test]
fn push_undo_clears_redo_but_reinstate_preserves_it() {
let mut stack = NodeUndoRedoStack::new(NodeId::new(1));
stack.push_redo(op(1, 1));
stack.push_redo(op(2, 1));
assert_eq!(stack.redo_stack.len(), 2);
stack.push_undo(op(3, 1));
assert_eq!(stack.redo_stack.len(), 0);
stack.push_redo(op(4, 1));
stack.push_redo(op(5, 1));
stack.push_undo_preserving_redo(op(6, 1));
assert_eq!(stack.redo_stack.len(), 2);
assert!(stack.can_undo());
}
#[test]
fn content_snapshots_replace_lookup_and_evict() {
let mut mgr = UndoRedoManager::new();
mgr.store_content_snapshot(7, Vec::new(), Vec::new());
assert!(mgr.get_content_snapshot(7).is_some());
assert!(mgr.get_content_snapshot(8).is_none());
mgr.store_content_snapshot(7, Vec::new(), Vec::new());
assert_eq!(mgr.content_snapshots.len(), 1);
for id in 100..(100 + MAX_CONTENT_SNAPSHOTS) {
mgr.store_content_snapshot(id, Vec::new(), Vec::new());
}
assert!(mgr.content_snapshots.len() <= MAX_CONTENT_SNAPSHOTS);
assert!(mgr.get_content_snapshot(7).is_none(), "oldest entry evicted");
assert!(mgr
.get_content_snapshot(100 + MAX_CONTENT_SNAPSHOTS - 1)
.is_some());
}
#[test]
fn reinstate_undo_keeps_manager_redo_stack() {
let mut mgr = UndoRedoManager::new();
mgr.record_operation(op(1, 3).changeset, op(1, 3).pre_state);
let popped = mgr.pop_undo(NodeId::new(3)).unwrap();
mgr.push_redo(popped);
let redone = mgr.pop_redo(NodeId::new(3)).unwrap();
mgr.push_redo(op(2, 3));
mgr.reinstate_undo(redone);
assert!(mgr.can_undo(NodeId::new(3)));
assert!(mgr.can_redo(NodeId::new(3)), "redo stack preserved");
}
}