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//! Each snapshot also holds where the caret stood in that state, where the
10//! view has one, so that taking an edit back or putting it back shows the
11//! place it happened.
12//
13// Author: David M. Anderson
14// Built with AI assistance (Claude, Anthropic)
15
16use egui_richedit::Position;
17use odox_core::Element;
18
19/// How many edits can be taken back. Past this the oldest is forgotten.
20const DEPTH: usize = 100;
21
22/// Where a caret stood: a paragraph's path under the root the view draws, and
23/// a character offset into its text.
24pub type Caret = Position<Vec<usize>>;
25
26/// A state of the content tree and where the caret was in it.
27struct Snapshot {
28    content: Element,
29    caret: Option<Caret>,
30}
31
32/// The shell's editing state, handed to the view on every frame.
33#[derive(Default)]
34pub struct Editing {
35    /// Whether the window is in edit mode. Outside it a view draws and
36    /// selects and never opens an editor.
37    pub on: bool,
38    /// A question is up over the window, and the keys belong to it: a view
39    /// does not open an editor on the Enter that answers it.
40    pub asking: bool,
41    undo: Vec<Snapshot>,
42    redo: Vec<Snapshot>,
43    /// The depth of the undo stack when the document was last read or saved,
44    /// which is the state the file on disk holds. `None` once that state can no
45    /// longer be reached by undoing, because a new edit was made below it.
46    saved_at: Option<usize>,
47}
48
49impl Editing {
50    /// A document was just read or closed: nothing to undo, nothing changed.
51    pub fn reset(&mut self) {
52        self.undo.clear();
53        self.redo.clear();
54        self.saved_at = Some(0);
55    }
56
57    /// Take a snapshot of the content tree before an edit changes it.
58    ///
59    /// Every edit calls this first, with the tree as it stands, and then
60    /// mutates. A redo history is discarded, because the edit that follows is
61    /// a new branch.
62    pub fn record(&mut self, content: &Element) {
63        self.record_snapshot(content.clone(), None);
64    }
65
66    /// The same, for a snapshot already taken, with where the caret stood in
67    /// it: an editor that learns only after an edit whether it succeeded
68    /// takes the tree before trying, and records it once it has.
69    pub fn record_snapshot(&mut self, content: Element, caret: Option<Caret>) {
70        if self.saved_at.is_some_and(|depth| depth > self.undo.len()) {
71            // The saved state was above this point and is now off the line.
72            self.saved_at = None;
73        }
74        self.redo.clear();
75        self.undo.push(Snapshot { content, caret });
76        if self.undo.len() > DEPTH {
77            self.undo.remove(0);
78            self.saved_at = self.saved_at.and_then(|depth| depth.checked_sub(1));
79        }
80    }
81
82    /// Take the last edit back. Given the tree as it stands and where the
83    /// caret is, so that the edit can be redone; answers the tree to put in
84    /// its place and where the caret was in it.
85    pub fn undo(
86        &mut self,
87        current: &Element,
88        caret: Option<Caret>,
89    ) -> Option<(Element, Option<Caret>)> {
90        let previous = self.undo.pop()?;
91        self.redo.push(Snapshot {
92            content: current.clone(),
93            caret,
94        });
95        Some((previous.content, previous.caret))
96    }
97
98    /// Put back the edit last taken back, the same way.
99    pub fn redo(
100        &mut self,
101        current: &Element,
102        caret: Option<Caret>,
103    ) -> Option<(Element, Option<Caret>)> {
104        let next = self.redo.pop()?;
105        self.undo.push(Snapshot {
106            content: current.clone(),
107            caret,
108        });
109        Some((next.content, next.caret))
110    }
111
112    /// Whether there is anything to undo.
113    pub fn can_undo(&self) -> bool {
114        !self.undo.is_empty()
115    }
116
117    /// Whether there is anything to redo.
118    pub fn can_redo(&self) -> bool {
119        !self.redo.is_empty()
120    }
121
122    /// Whether the document differs from the file it was read from or last
123    /// saved to. Undoing back to that state answers false again.
124    pub fn modified(&self) -> bool {
125        self.saved_at != Some(self.undo.len())
126    }
127
128    /// The document was written: what it holds now is what the file holds.
129    pub fn mark_saved(&mut self) {
130        self.saved_at = Some(self.undo.len());
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::Editing;
137    use odox_core::{Element, Ns};
138
139    fn tree(text: &str) -> Element {
140        let mut e = Element::new("text", "p", Ns::Text);
141        e.children.push(odox_core::Node::Text(text.to_owned()));
142        e
143    }
144
145    #[test]
146    fn undo_returns_to_the_saved_state_and_redo_leaves_it() {
147        let mut editing = Editing::default();
148        editing.reset();
149        assert!(!editing.modified());
150
151        let mut current = tree("one");
152        editing.record(&current);
153        current = tree("two");
154        assert!(editing.modified());
155
156        current = editing
157            .undo(&current, None)
158            .map(|(tree, _)| tree)
159            .expect("something to undo");
160        assert_eq!(current, tree("one"));
161        assert!(!editing.modified(), "undone to what the file holds");
162
163        current = editing
164            .redo(&current, None)
165            .map(|(tree, _)| tree)
166            .expect("something to redo");
167        assert_eq!(current, tree("two"));
168        assert!(editing.modified());
169    }
170
171    #[test]
172    fn a_new_edit_below_the_saved_state_makes_it_unreachable() {
173        let mut editing = Editing::default();
174        editing.reset();
175        let mut current = tree("one");
176        editing.record(&current);
177        current = tree("two");
178        editing.mark_saved();
179        assert!(!editing.modified());
180
181        // Back to "one", then a different second edit: the saved "two" is on
182        // a branch that no longer exists.
183        current = editing
184            .undo(&current, None)
185            .map(|(tree, _)| tree)
186            .expect("something to undo");
187        assert!(editing.modified());
188        editing.record(&current);
189        assert!(
190            editing.modified(),
191            "the same depth is not the same document"
192        );
193        assert!(!editing.can_redo(), "the branch the save was on is gone");
194    }
195
196    #[test]
197    fn undo_and_redo_answer_where_the_caret_was() {
198        let mut editing = Editing::default();
199        editing.reset();
200        let before = super::Caret::new(vec![0], 1);
201        let after = super::Caret::new(vec![0], 4);
202        editing.record_snapshot(tree("one"), Some(before.clone()));
203        let (_, caret) = editing
204            .undo(&tree("one more"), Some(after.clone()))
205            .expect("something to undo");
206        assert_eq!(caret, Some(before), "where the edit began");
207        let (_, caret) = editing.redo(&tree("one"), None).expect("something to redo");
208        assert_eq!(caret, Some(after), "where the caret was when it was undone");
209    }
210
211    #[test]
212    fn the_stack_is_bounded() {
213        let mut editing = Editing::default();
214        editing.reset();
215        for i in 0..(super::DEPTH + 10) {
216            editing.record(&tree(&i.to_string()));
217        }
218        assert!(editing.modified());
219        let mut undone = 0;
220        let mut current = tree("last");
221        while let Some(previous) = editing.undo(&current, None).map(|(tree, _)| tree) {
222            current = previous;
223            undone += 1;
224        }
225        assert_eq!(undone, super::DEPTH);
226        assert!(
227            editing.modified(),
228            "the saved state was forgotten with the oldest snapshots"
229        );
230    }
231}