odox-ui 0.7.0

The shared window of the odox OpenDocument editors: style mapping, document rendering, editing and chrome
Documentation
//! What the shell keeps about a document being edited: whether editing is on,
//! what has changed since the file was read, and how to take it back.
//!
//! Undo is a stack of snapshots of the content tree rather than a log of
//! commands. The tree derives `Clone`, a document of the size these
//! applications open clones in well under a millisecond, and commands would buy
//! redo granularity nobody has asked for. DESIGN.md ยง11.
//!
//! Each snapshot also holds where the caret stood in that state, where the
//! view has one, so that taking an edit back or putting it back shows the
//! place it happened.
//
// Author: David M. Anderson
// Built with AI assistance (Claude, Anthropic)

use egui_richedit::Position;
use odox_core::Element;

/// How many edits can be taken back. Past this the oldest is forgotten.
const DEPTH: usize = 100;

/// Where a caret stood: a paragraph's path under the root the view draws, and
/// a character offset into its text.
pub type Caret = Position<Vec<usize>>;

/// A state of the content tree and where the caret was in it.
struct Snapshot {
    content: Element,
    caret: Option<Caret>,
}

/// The shell's editing state, handed to the view on every frame.
#[derive(Default)]
pub struct Editing {
    /// Whether the window is in edit mode. Outside it a view draws and
    /// selects and never opens an editor.
    pub on: bool,
    /// A question is up over the window, and the keys belong to it: a view
    /// does not open an editor on the Enter that answers it.
    pub asking: bool,
    undo: Vec<Snapshot>,
    redo: Vec<Snapshot>,
    /// The depth of the undo stack when the document was last read or saved,
    /// which is the state the file on disk holds. `None` once that state can no
    /// longer be reached by undoing, because a new edit was made below it.
    saved_at: Option<usize>,
    /// Counts every change to the content, including the ones an undo step
    /// absorbs, so that whatever is derived from the text can tell it is stale.
    revision: u64,
}

impl Editing {
    /// A document was just read or closed: nothing to undo, nothing changed.
    pub fn reset(&mut self) {
        self.undo.clear();
        self.redo.clear();
        self.saved_at = Some(0);
        self.touch();
    }

    /// The content changed, in a way that need not have begun an undo step.
    pub fn touch(&mut self) {
        self.revision += 1;
    }

    /// A number that is different whenever the text may be.
    pub fn revision(&self) -> u64 {
        self.revision
    }

    /// Take a snapshot of the content tree before an edit changes it.
    ///
    /// Every edit calls this first, with the tree as it stands, and then
    /// mutates. A redo history is discarded, because the edit that follows is
    /// a new branch.
    pub fn record(&mut self, content: &Element) {
        self.record_snapshot(content.clone(), None);
    }

    /// The same, for a snapshot already taken, with where the caret stood in
    /// it: an editor that learns only after an edit whether it succeeded
    /// takes the tree before trying, and records it once it has.
    pub fn record_snapshot(&mut self, content: Element, caret: Option<Caret>) {
        if self.saved_at.is_some_and(|depth| depth > self.undo.len()) {
            // The saved state was above this point and is now off the line.
            self.saved_at = None;
        }
        self.redo.clear();
        self.touch();
        self.undo.push(Snapshot { content, caret });
        if self.undo.len() > DEPTH {
            self.undo.remove(0);
            self.saved_at = self.saved_at.and_then(|depth| depth.checked_sub(1));
        }
    }

    /// Take the last edit back. Given the tree as it stands and where the
    /// caret is, so that the edit can be redone; answers the tree to put in
    /// its place and where the caret was in it.
    pub fn undo(
        &mut self,
        current: &Element,
        caret: Option<Caret>,
    ) -> Option<(Element, Option<Caret>)> {
        let previous = self.undo.pop()?;
        self.touch();
        self.redo.push(Snapshot {
            content: current.clone(),
            caret,
        });
        Some((previous.content, previous.caret))
    }

    /// Put back the edit last taken back, the same way.
    pub fn redo(
        &mut self,
        current: &Element,
        caret: Option<Caret>,
    ) -> Option<(Element, Option<Caret>)> {
        let next = self.redo.pop()?;
        self.touch();
        self.undo.push(Snapshot {
            content: current.clone(),
            caret,
        });
        Some((next.content, next.caret))
    }

    /// Whether there is anything to undo.
    pub fn can_undo(&self) -> bool {
        !self.undo.is_empty()
    }

    /// Whether there is anything to redo.
    pub fn can_redo(&self) -> bool {
        !self.redo.is_empty()
    }

    /// Whether the document differs from the file it was read from or last
    /// saved to. Undoing back to that state answers false again.
    pub fn modified(&self) -> bool {
        self.saved_at != Some(self.undo.len())
    }

    /// The document was written: what it holds now is what the file holds.
    pub fn mark_saved(&mut self) {
        self.saved_at = Some(self.undo.len());
    }
}

#[cfg(test)]
mod tests {
    use super::Editing;
    use odox_core::{Element, Ns};

    fn tree(text: &str) -> Element {
        let mut e = Element::new("text", "p", Ns::Text);
        e.children.push(odox_core::Node::Text(text.to_owned()));
        e
    }

    #[test]
    fn undo_returns_to_the_saved_state_and_redo_leaves_it() {
        let mut editing = Editing::default();
        editing.reset();
        assert!(!editing.modified());

        let mut current = tree("one");
        editing.record(&current);
        current = tree("two");
        assert!(editing.modified());

        current = editing
            .undo(&current, None)
            .map(|(tree, _)| tree)
            .expect("something to undo");
        assert_eq!(current, tree("one"));
        assert!(!editing.modified(), "undone to what the file holds");

        current = editing
            .redo(&current, None)
            .map(|(tree, _)| tree)
            .expect("something to redo");
        assert_eq!(current, tree("two"));
        assert!(editing.modified());
    }

    #[test]
    fn a_new_edit_below_the_saved_state_makes_it_unreachable() {
        let mut editing = Editing::default();
        editing.reset();
        let mut current = tree("one");
        editing.record(&current);
        current = tree("two");
        editing.mark_saved();
        assert!(!editing.modified());

        // Back to "one", then a different second edit: the saved "two" is on
        // a branch that no longer exists.
        current = editing
            .undo(&current, None)
            .map(|(tree, _)| tree)
            .expect("something to undo");
        assert!(editing.modified());
        editing.record(&current);
        assert!(
            editing.modified(),
            "the same depth is not the same document"
        );
        assert!(!editing.can_redo(), "the branch the save was on is gone");
    }

    #[test]
    fn undo_and_redo_answer_where_the_caret_was() {
        let mut editing = Editing::default();
        editing.reset();
        let before = super::Caret::new(vec![0], 1);
        let after = super::Caret::new(vec![0], 4);
        editing.record_snapshot(tree("one"), Some(before.clone()));
        let (_, caret) = editing
            .undo(&tree("one more"), Some(after.clone()))
            .expect("something to undo");
        assert_eq!(caret, Some(before), "where the edit began");
        let (_, caret) = editing.redo(&tree("one"), None).expect("something to redo");
        assert_eq!(caret, Some(after), "where the caret was when it was undone");
    }

    #[test]
    fn the_stack_is_bounded() {
        let mut editing = Editing::default();
        editing.reset();
        for i in 0..(super::DEPTH + 10) {
            editing.record(&tree(&i.to_string()));
        }
        assert!(editing.modified());
        let mut undone = 0;
        let mut current = tree("last");
        while let Some(previous) = editing.undo(&current, None).map(|(tree, _)| tree) {
            current = previous;
            undone += 1;
        }
        assert_eq!(undone, super::DEPTH);
        assert!(
            editing.modified(),
            "the saved state was forgotten with the oldest snapshots"
        );
    }
}