Version 1

This commit is contained in:
2026-09-09 21:19:44 +02:00
commit f35e5c82e5
16 changed files with 11148 additions and 0 deletions
+266
View File
@@ -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();
}
}