//! The open document: a [`Canvas`] plus its file location, dirty state and //! undo history. use std::fs; use std::path::{Path, PathBuf}; use crate::model::Canvas; /// How many undo steps are kept. const UNDO_LIMIT: usize = 256; /// A canvas together with everything the editor needs to know about it. pub struct Document { pub canvas: Canvas, path: Option, dirty: bool, undo: Vec, redo: Vec, /// Label of the change currently being recorded, used to coalesce the many /// small mutations of a single drag into one undo step. open_change: Option, } struct Snapshot { canvas: Canvas, label: String, } impl Default for Document { fn default() -> Self { Self::new() } } impl Document { pub fn new() -> Self { Self { canvas: Canvas::default(), path: None, dirty: false, undo: Vec::new(), redo: Vec::new(), open_change: None, } } /// Reads a `.canvas` file from disk. pub fn open(path: &Path) -> Result { let text = fs::read_to_string(path) .map_err(|e| format!("Could not read {}: {e}", path.display()))?; let canvas = Canvas::from_json(&text) .map_err(|e| format!("{} is not a valid JSON Canvas file: {e}", path.display()))?; Ok(Self { canvas, path: Some(path.to_path_buf()), dirty: false, undo: Vec::new(), redo: Vec::new(), open_change: None, }) } /// Writes the canvas to `path` and makes it the document's location. pub fn save_as(&mut self, path: &Path) -> Result<(), String> { fs::write(path, self.canvas.to_json()) .map_err(|e| format!("Could not write {}: {e}", path.display()))?; self.path = Some(path.to_path_buf()); self.dirty = false; Ok(()) } /// Writes the canvas back to the file it was loaded from. /// /// Returns `Ok(false)` when the document has no path yet and the caller /// needs to ask the user for one. pub fn save(&mut self) -> Result { let Some(path) = self.path.clone() else { return Ok(false); }; self.save_as(&path)?; Ok(true) } pub fn path(&self) -> Option<&Path> { self.path.as_deref() } /// The directory the document lives in, used to resolve relative node paths. pub fn base_dir(&self) -> Option { self.path.as_ref()?.parent().map(Path::to_path_buf) } pub fn is_dirty(&self) -> bool { self.dirty } /// Name shown in the window title and the status bar. pub fn display_name(&self) -> String { self.path .as_ref() .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "Untitled.canvas".to_owned()) } // -- undo ------------------------------------------------------------- /// Records the state before a change. Call this *before* mutating. /// /// Repeated calls with the same `label` while a gesture is open (see /// [`Document::end_change`]) only record the first state, so an entire drag /// undoes in one step. pub fn begin_change(&mut self, label: &str) { if self.open_change.as_deref() == Some(label) { self.dirty = true; return; } self.push_undo(label); self.open_change = Some(label.to_owned()); } /// Records a single, self-contained change. pub fn change(&mut self, label: &str) { self.end_change(); self.push_undo(label); } /// Closes the current gesture so the next change starts a new undo step. pub fn end_change(&mut self) { self.open_change = None; } fn push_undo(&mut self, label: &str) { self.undo.push(Snapshot { canvas: self.canvas.clone(), label: label.to_owned(), }); if self.undo.len() > UNDO_LIMIT { self.undo.remove(0); } self.redo.clear(); self.dirty = true; } pub fn can_undo(&self) -> bool { !self.undo.is_empty() } pub fn can_redo(&self) -> bool { !self.redo.is_empty() } pub fn undo_label(&self) -> Option<&str> { self.undo.last().map(|s| s.label.as_str()) } pub fn redo_label(&self) -> Option<&str> { self.redo.last().map(|s| s.label.as_str()) } /// Steps back one change, returning its label. pub fn undo(&mut self) -> Option { let snapshot = self.undo.pop()?; self.redo.push(Snapshot { canvas: std::mem::replace(&mut self.canvas, snapshot.canvas), label: snapshot.label.clone(), }); self.open_change = None; self.dirty = true; Some(snapshot.label) } /// Steps forward one change, returning its label. pub fn redo(&mut self) -> Option { let snapshot = self.redo.pop()?; self.undo.push(Snapshot { canvas: std::mem::replace(&mut self.canvas, snapshot.canvas), label: snapshot.label.clone(), }); self.open_change = None; self.dirty = true; Some(snapshot.label) } } #[cfg(test)] mod tests { use super::*; use crate::model::{Node, NodeKind}; fn text_node(id: &str) -> Node { Node::new(id, NodeKind::Text { text: id.into() }, 0, 0, 250, 60) } #[test] fn undo_and_redo_restore_the_canvas() { let mut doc = Document::new(); doc.change("Add node"); doc.canvas.nodes.push(text_node("a")); assert!(doc.is_dirty()); assert_eq!(doc.undo().as_deref(), Some("Add node")); assert!(doc.canvas.nodes.is_empty()); assert_eq!(doc.redo().as_deref(), Some("Add node")); assert_eq!(doc.canvas.nodes.len(), 1); } #[test] fn a_gesture_collapses_into_one_undo_step() { let mut doc = Document::new(); doc.canvas.nodes.push(text_node("a")); for step in 1..=10 { doc.begin_change("Move"); doc.canvas.nodes[0].x = step; } doc.end_change(); doc.undo(); assert_eq!(doc.canvas.nodes[0].x, 0); assert!(!doc.can_undo()); } #[test] fn a_new_change_clears_the_redo_stack() { let mut doc = Document::new(); doc.change("One"); doc.canvas.nodes.push(text_node("a")); doc.undo(); doc.change("Two"); doc.canvas.nodes.push(text_node("b")); assert!(!doc.can_redo()); } #[test] fn saving_and_loading_round_trips( ) -> Result<(), Box> { let dir = std::env::temp_dir().join(format!("jsoncanvas-test-{}", crate::model::random_id())); fs::create_dir_all(&dir)?; let path = dir.join("test.canvas"); let mut doc = Document::new(); doc.canvas.nodes.push(text_node("a")); doc.save_as(&path)?; assert!(!doc.is_dirty()); let loaded = Document::open(&path)?; assert_eq!(loaded.canvas, doc.canvas); assert_eq!(loaded.display_name(), "test.canvas"); fs::remove_dir_all(&dir)?; Ok(()) } #[test] fn opening_a_broken_file_reports_an_error() { let dir = std::env::temp_dir().join(format!("jsoncanvas-test-{}", crate::model::random_id())); fs::create_dir_all(&dir).unwrap(); let path = dir.join("bad.canvas"); fs::write(&path, "{ not json").unwrap(); let err = match Document::open(&path) { Ok(_) => panic!("expected the load to fail"), Err(err) => err, }; assert!(err.contains("not a valid JSON Canvas file"), "{err}"); fs::remove_dir_all(&dir).unwrap(); } }