Version 1
This commit is contained in:
+266
@@ -0,0 +1,266 @@
|
||||
//! 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<PathBuf>,
|
||||
dirty: bool,
|
||||
undo: Vec<Snapshot>,
|
||||
redo: Vec<Snapshot>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
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<bool, String> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<dyn std::error::Error>> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user