267 lines
7.7 KiB
Rust
267 lines
7.7 KiB
Rust
//! 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();
|
|
}
|
|
}
|