Skip to main content

odox_ui/
edit.rs

1//! What the shell keeps about a document being edited: whether editing is on,
2//! what has changed since the file was read, and how to take it back.
3//!
4//! Undo is a stack of snapshots of the content tree rather than a log of
5//! commands. The tree derives `Clone`, a document of the size these
6//! applications open clones in well under a millisecond, and commands would buy
7//! redo granularity nobody has asked for. DESIGN.md ยง11.
8//
9// Author: David M. Anderson
10// Built with AI assistance (Claude, Anthropic)
11
12use odox_core::Element;
13
14/// How many edits can be taken back. Past this the oldest is forgotten.
15const DEPTH: usize = 100;
16
17/// The shell's editing state, handed to the view on every frame.
18#[derive(Default)]
19pub struct Editing {
20    /// Whether the window is in edit mode. Outside it a view draws and
21    /// selects and never opens an editor.
22    pub on: bool,
23    /// A question is up over the window, and the keys belong to it: a view
24    /// does not open an editor on the Enter that answers it.
25    pub asking: bool,
26    undo: Vec<Element>,
27    redo: Vec<Element>,
28    /// The depth of the undo stack when the document was last read or saved,
29    /// which is the state the file on disk holds. `None` once that state can no
30    /// longer be reached by undoing, because a new edit was made below it.
31    saved_at: Option<usize>,
32}
33
34impl Editing {
35    /// A document was just read or closed: nothing to undo, nothing changed.
36    pub fn reset(&mut self) {
37        self.undo.clear();
38        self.redo.clear();
39        self.saved_at = Some(0);
40    }
41
42    /// Take a snapshot of the content tree before an edit changes it.
43    ///
44    /// Every edit calls this first, with the tree as it stands, and then
45    /// mutates. A redo history is discarded, because the edit that follows is
46    /// a new branch.
47    pub fn record(&mut self, content: &Element) {
48        if self.saved_at.is_some_and(|depth| depth > self.undo.len()) {
49            // The saved state was above this point and is now off the line.
50            self.saved_at = None;
51        }
52        self.redo.clear();
53        self.undo.push(content.clone());
54        if self.undo.len() > DEPTH {
55            self.undo.remove(0);
56            self.saved_at = self.saved_at.and_then(|depth| depth.checked_sub(1));
57        }
58    }
59
60    /// Take the last edit back. Given the tree as it stands, so that the edit
61    /// can be redone; answers the tree to put in its place.
62    pub fn undo(&mut self, current: &Element) -> Option<Element> {
63        let previous = self.undo.pop()?;
64        self.redo.push(current.clone());
65        Some(previous)
66    }
67
68    /// Put back the edit last taken back.
69    pub fn redo(&mut self, current: &Element) -> Option<Element> {
70        let next = self.redo.pop()?;
71        self.undo.push(current.clone());
72        Some(next)
73    }
74
75    /// Whether there is anything to undo.
76    pub fn can_undo(&self) -> bool {
77        !self.undo.is_empty()
78    }
79
80    /// Whether there is anything to redo.
81    pub fn can_redo(&self) -> bool {
82        !self.redo.is_empty()
83    }
84
85    /// Whether the document differs from the file it was read from or last
86    /// saved to. Undoing back to that state answers false again.
87    pub fn modified(&self) -> bool {
88        self.saved_at != Some(self.undo.len())
89    }
90
91    /// The document was written: what it holds now is what the file holds.
92    pub fn mark_saved(&mut self) {
93        self.saved_at = Some(self.undo.len());
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::Editing;
100    use odox_core::{Element, Ns};
101
102    fn tree(text: &str) -> Element {
103        let mut e = Element::new("text", "p", Ns::Text);
104        e.children.push(odox_core::Node::Text(text.to_owned()));
105        e
106    }
107
108    #[test]
109    fn undo_returns_to_the_saved_state_and_redo_leaves_it() {
110        let mut editing = Editing::default();
111        editing.reset();
112        assert!(!editing.modified());
113
114        let mut current = tree("one");
115        editing.record(&current);
116        current = tree("two");
117        assert!(editing.modified());
118
119        current = editing.undo(&current).expect("something to undo");
120        assert_eq!(current, tree("one"));
121        assert!(!editing.modified(), "undone to what the file holds");
122
123        current = editing.redo(&current).expect("something to redo");
124        assert_eq!(current, tree("two"));
125        assert!(editing.modified());
126    }
127
128    #[test]
129    fn a_new_edit_below_the_saved_state_makes_it_unreachable() {
130        let mut editing = Editing::default();
131        editing.reset();
132        let mut current = tree("one");
133        editing.record(&current);
134        current = tree("two");
135        editing.mark_saved();
136        assert!(!editing.modified());
137
138        // Back to "one", then a different second edit: the saved "two" is on
139        // a branch that no longer exists.
140        current = editing.undo(&current).expect("something to undo");
141        assert!(editing.modified());
142        editing.record(&current);
143        assert!(
144            editing.modified(),
145            "the same depth is not the same document"
146        );
147        assert!(!editing.can_redo(), "the branch the save was on is gone");
148    }
149
150    #[test]
151    fn the_stack_is_bounded() {
152        let mut editing = Editing::default();
153        editing.reset();
154        for i in 0..(super::DEPTH + 10) {
155            editing.record(&tree(&i.to_string()));
156        }
157        assert!(editing.modified());
158        let mut undone = 0;
159        let mut current = tree("last");
160        while let Some(previous) = editing.undo(&current) {
161            current = previous;
162            undone += 1;
163        }
164        assert_eq!(undone, super::DEPTH);
165        assert!(
166            editing.modified(),
167            "the saved state was forgotten with the oldest snapshots"
168        );
169    }
170}