From 69aa7eee58a3b4fec6a4685b78806b9636d95623 Mon Sep 17 00:00:00 2001 From: Skkeye Date: Thu, 1 Oct 2026 00:54:49 -0400 Subject: [PATCH] Add the data model and the document file format The model holds the rules: a document is a tree under one root, with loose nodes beside it and cross-links between any two nodes. Every change runs as a transaction that rolls back on error and returns an undo step (the old and new values of whatever it touched), so undo and redo need no code of their own per command. Text from outside (titles, tags, names) is checked once, when it becomes a typed value. Folding (Open, Auto, Closed, plus focus) is one function that every view will share. storage::document_file reads and writes `.cum` files in RON, with multi-line bodies kept readable, `\n` line endings on every OS, and errors that name the node they're about. The tests cover the tree rules, 500 random edits undone and redone step by step, folding, and the file format (the example from DESIGN.md is written back character for character). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 125 ++++++++ Cargo.toml | 7 + src/lib.rs | 8 + src/model/document.rs | 492 ++++++++++++++++++++++++++++++ src/model/edit.rs | 558 +++++++++++++++++++++++++++++++++++ src/model/fold.rs | 105 +++++++ src/model/geometry.rs | 66 +++++ src/model/history.rs | 148 ++++++++++ src/model/ids.rs | 89 ++++++ src/model/link.rs | 94 ++++++ src/model/look.rs | 89 ++++++ src/model/mod.rs | 85 ++++++ src/model/node.rs | 80 +++++ src/model/portal.rs | 56 ++++ src/model/text.rs | 298 +++++++++++++++++++ src/storage/document_file.rs | 528 +++++++++++++++++++++++++++++++++ src/storage/mod.rs | 4 + 17 files changed, 2832 insertions(+) create mode 100644 src/model/document.rs create mode 100644 src/model/edit.rs create mode 100644 src/model/fold.rs create mode 100644 src/model/geometry.rs create mode 100644 src/model/history.rs create mode 100644 src/model/ids.rs create mode 100644 src/model/link.rs create mode 100644 src/model/look.rs create mode 100644 src/model/mod.rs create mode 100644 src/model/node.rs create mode 100644 src/model/portal.rs create mode 100644 src/model/text.rs create mode 100644 src/storage/document_file.rs create mode 100644 src/storage/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 919eafc..f6aa20d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,131 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + [[package]] name = "cumulus" version = "0.1.0" +dependencies = [ + "ron", + "serde", + "thiserror", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" diff --git a/Cargo.toml b/Cargo.toml index 1cb123f..fb519e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,13 @@ rust-version = "1.90" description = "Brainstorm in the terminal: idea graphs on a 2D canvas, with vim keys and the mouse." publish = false +[dependencies] +# Document files are RON (Rust Object Notation), read and written through serde. +serde = { version = "1", features = ["derive"] } +ron = "0.12" +# Error types with readable messages, for the parts that report problems. +thiserror = "2" + [lints.rust] unsafe_code = "forbid" diff --git a/src/lib.rs b/src/lib.rs index 662b971..bb650af 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,14 @@ //! cumulus is a program, not a library for other projects. It's split into a //! library and a small `main.rs` so the examples in `examples/` can run the //! same code while the app is being built (DESIGN.md, "Build order"). +//! +//! | Module | What's in it | +//! |-------------|--------------------------------------------------------------| +//! | [`model`] | the data and its rules: documents, nodes, links, undo, folds | +//! | [`storage`] | files: documents (RON) | + +pub mod model; +pub mod storage; /// The line `cumulus --version` prints: the version, and the git commit it was /// built from (recorded by `build.rs`). diff --git a/src/model/document.rs b/src/model/document.rs new file mode 100644 index 0000000..5a9d694 --- /dev/null +++ b/src/model/document.rs @@ -0,0 +1,492 @@ +//! A document: a tree of nodes under one root, loose nodes beside it, and +//! cross-links between any of them. + +use std::collections::BTreeMap; + +use thiserror::Error; + +use super::geometry::CanvasPos; +use super::ids::{DocUid, NodeId}; +use super::link::{Link, LinkKey}; +use super::look::LineLook; +use super::node::{Content, Node}; +use super::portal::{Backlink, Portal}; +use super::text::Title; + +/// Where a node sits in its document's tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Place { + /// The document's main topic. There is exactly one, and its title is the + /// document's name. + Root, + /// A top-level idea beside the root's tree, not attached to anything yet. + Loose, + /// Attached under another node. + Child { + /// The node above this one. + parent: NodeId, + /// How the line from the parent is drawn. + branch: LineLook, + }, +} + +/// A node, with where it sits in the tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Entry { + pub(super) node: Node, + pub(super) place: Place, + /// Its children, in order. + pub(super) children: Vec, +} + +/// One brainstorm: a tree of nodes under a single root, loose nodes beside +/// it, cross-links between any two nodes, and the backlinks of portals in +/// other documents that lead here. +/// +/// The fields are private and every change goes through +/// [`Document::edit`](Document::edit), so these rules always hold: +/// - there is exactly one root, and it can't be removed or given a parent; +/// - a node has at most one parent, and there are no cycles; +/// - a link joins two different nodes that both exist, at most one link per +/// pair. +#[derive(Debug, Clone)] +pub struct Document { + uid: DocUid, + root: NodeId, + pub(super) entries: BTreeMap, + /// The loose nodes, in order. + pub(super) loose: Vec, + pub(super) links: BTreeMap, + backlinks: Vec, + /// The number the next new node gets. It only ever grows, so ids aren't + /// handed out twice, not even after an undo. + pub(super) next_id: u32, +} + +impl PartialEq for Document { + /// Two documents are equal when they hold the same things. `next_id` is + /// left out: it only says which number comes next. + fn eq(&self, other: &Document) -> bool { + self.uid == other.uid + && self.root == other.root + && self.entries == other.entries + && self.loose == other.loose + && self.links == other.links + && self.backlinks == other.backlinks + } +} + +impl Eq for Document {} + +/// A node as read from a file: the node, plus where the file says it goes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeRecord { + pub id: NodeId, + pub parent: Option, + /// How the line from the parent is drawn (ignored without a parent). + pub branch: LineLook, + pub node: Node, +} + +/// Why the nodes and links read from a file don't make a valid document. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum StructureError { + #[error("node {0} appears twice")] + DuplicateNode(NodeId), + #[error("the root (node {0}) isn't in the file")] + MissingRoot(NodeId), + #[error("the root (node {0}) can't have a parent")] + RootWithParent(NodeId), + #[error("node {child} has parent {parent}, which isn't in the file")] + UnknownParent { child: NodeId, parent: NodeId }, + #[error("node {0} is inside its own branch")] + Cycle(NodeId), + #[error("node ids must be smaller than {}", u32::MAX)] + IdTooLarge, + #[error("a link points at node {0}, which isn't in the file")] + UnknownLinkEnd(NodeId), + #[error("nodes {0} and {1} are linked twice")] + DuplicateLink(NodeId, NodeId), +} + +impl Document { + /// A new document with just its root, at the middle of the canvas. + pub fn new(title: Title) -> Document { + Document::with_uid(title, DocUid::random()) + } + + /// A new document with just its root, and a uid chosen by the caller. + pub fn with_uid(title: Title, uid: DocUid) -> Document { + let root = NodeId::new(1); + let entry = + Entry { node: Node::idea(title, CanvasPos::default()), place: Place::Root, children: Vec::new() }; + Document { + uid, + root, + entries: BTreeMap::from([(root, entry)]), + loose: Vec::new(), + links: BTreeMap::new(), + backlinks: Vec::new(), + next_id: 2, + } + } + + /// Builds a document from what a file holds, checking every rule. + /// Siblings keep the order they have in `records`. + pub fn from_records( + uid: DocUid, + root: NodeId, + records: Vec, + links: Vec, + backlinks: Vec, + ) -> Result { + let mut entries = BTreeMap::new(); + let mut order = Vec::with_capacity(records.len()); + for record in records { + if record.id.get() == u32::MAX { + return Err(StructureError::IdTooLarge); + } + let place = match record.parent { + Some(_) if record.id == root => return Err(StructureError::RootWithParent(root)), + Some(parent) => Place::Child { parent, branch: record.branch }, + None if record.id == root => Place::Root, + None => Place::Loose, + }; + order.push((record.id, record.parent)); + let entry = Entry { node: record.node, place, children: Vec::new() }; + if entries.insert(record.id, entry).is_some() { + return Err(StructureError::DuplicateNode(record.id)); + } + } + if !entries.contains_key(&root) { + return Err(StructureError::MissingRoot(root)); + } + + let mut loose = Vec::new(); + for (id, parent) in order { + match parent { + Some(parent) => match entries.get_mut(&parent) { + Some(entry) => entry.children.push(id), + None => return Err(StructureError::UnknownParent { child: id, parent }), + }, + None if id != root => loose.push(id), + None => {} + } + } + + // Climbing from any node must reach the top level. A node that keeps + // climbing for longer than there are nodes is going round in a cycle. + for &id in entries.keys() { + let mut current = id; + for steps in 0.. { + match entries.get(¤t).map(|entry| &entry.place) { + Some(Place::Child { parent, .. }) => current = *parent, + _ => break, + } + if steps > entries.len() { + return Err(StructureError::Cycle(id)); + } + } + } + + let mut link_map = BTreeMap::new(); + for link in links { + for end in [link.start(), link.end()] { + if !entries.contains_key(&end) { + return Err(StructureError::UnknownLinkEnd(end)); + } + } + let key = link.key(); + if link_map.insert(key, link).is_some() { + let (a, b) = key.nodes(); + return Err(StructureError::DuplicateLink(a, b)); + } + } + + // Ids are below u32::MAX (checked above), so this can't overflow. + let next_id = entries.keys().next_back().map_or(1, |id| id.get() + 1); + Ok(Document { uid, root, entries, loose, links: link_map, backlinks, next_id }) + } + + /// The document's uid, which stays the same when its file is renamed. + pub fn uid(&self) -> DocUid { + self.uid + } + + /// The root: the document's main topic. + pub fn root(&self) -> NodeId { + self.root + } + + /// The document's name: its root's title. + pub fn title(&self) -> &Title { + // The root can't be removed (see `Edit::remove_branch`), so it's + // always there. + self.entries[&self.root].node.title() + } + + /// How many nodes the document has, the root included. + pub fn node_count(&self) -> usize { + self.entries.len() + } + + /// Whether the document has a node with this id. + pub fn contains(&self, id: NodeId) -> bool { + self.entries.contains_key(&id) + } + + /// A node, if it exists. + pub fn node(&self, id: NodeId) -> Option<&Node> { + self.entries.get(&id).map(|entry| &entry.node) + } + + /// Where a node sits in the tree. + pub fn place(&self, id: NodeId) -> Option<&Place> { + self.entries.get(&id).map(|entry| &entry.place) + } + + /// A node's parent (none for the root and loose nodes). + pub fn parent(&self, id: NodeId) -> Option { + match self.place(id)? { + Place::Child { parent, .. } => Some(*parent), + Place::Root | Place::Loose => None, + } + } + + /// A node's children, in order (empty for an unknown id). + pub fn children(&self, id: NodeId) -> &[NodeId] { + self.entries.get(&id).map_or(&[], |entry| &entry.children) + } + + /// The loose nodes, in order. + pub fn loose(&self) -> &[NodeId] { + &self.loose + } + + /// The top level: the root, then the loose nodes. + pub fn top_level(&self) -> impl Iterator + '_ { + std::iter::once(self.root).chain(self.loose.iter().copied()) + } + + /// The node after `id` among its siblings. For the root, that's the first + /// loose node: the top level counts as one row of siblings. + pub fn next_sibling(&self, id: NodeId) -> Option { + let (siblings, index) = self.siblings(id)?; + siblings.get(index + 1).copied() + } + + /// The node before `id` among its siblings. + pub fn previous_sibling(&self, id: NodeId) -> Option { + let (siblings, index) = self.siblings(id)?; + siblings.get(index.checked_sub(1)?).copied() + } + + /// The row of siblings `id` is in, and its index there. + fn siblings(&self, id: NodeId) -> Option<(Vec, usize)> { + let siblings: Vec = match self.place(id)? { + Place::Child { parent, .. } => self.children(*parent).to_vec(), + Place::Root | Place::Loose => self.top_level().collect(), + }; + let index = siblings.iter().position(|&sibling| sibling == id)?; + Some((siblings, index)) + } + + /// The nodes above `id`: its parent, grandparent and so on up to the top. + pub fn ancestors(&self, id: NodeId) -> impl Iterator + '_ { + std::iter::successors(self.parent(id), |&node| self.parent(node)) + } + + /// Whether `id` is somewhere below `branch` (not `branch` itself). + pub fn is_inside(&self, id: NodeId, branch: NodeId) -> bool { + self.ancestors(id).any(|ancestor| ancestor == branch) + } + + /// How deep a node is: 0 at the top level, 1 for their children… + pub fn depth(&self, id: NodeId) -> usize { + self.ancestors(id).count() + } + + /// Every node below `id`, in reading order (each node before its + /// children). + pub fn descendants(&self, id: NodeId) -> Vec { + let mut found = Vec::new(); + let mut to_visit: Vec = self.children(id).iter().rev().copied().collect(); + while let Some(node) = to_visit.pop() { + found.push(node); + to_visit.extend(self.children(node).iter().rev()); + } + found + } + + /// `id` and every node below it, in reading order (empty for an unknown + /// id). + pub fn branch(&self, id: NodeId) -> Vec { + if !self.contains(id) { + return Vec::new(); + } + let mut branch = vec![id]; + branch.extend(self.descendants(id)); + branch + } + + /// Every node in reading order: the root's branch, then each loose node's + /// branch. Files list their nodes in this order. + pub fn outline(&self) -> Vec { + self.top_level().flat_map(|top| self.branch(top)).collect() + } + + /// Every cross-link, in the order of their keys. + pub fn links(&self) -> impl Iterator { + self.links.values() + } + + /// The link between two nodes, if there is one. + pub fn link(&self, key: LinkKey) -> Option<&Link> { + self.links.get(&key) + } + + /// The cross-links touching `id`, whichever way they point. + pub fn links_of(&self, id: NodeId) -> impl Iterator + '_ { + self.links.values().filter(move |link| link.key().touches(id)) + } + + /// The portals in other documents that lead here. + pub fn backlinks(&self) -> &[Backlink] { + &self.backlinks + } + + /// Replaces the backlinks. They're bookkeeping, kept in step with other + /// documents when they're saved, so this isn't an undoable edit. + pub fn set_backlinks(&mut self, backlinks: Vec) { + self.backlinks = backlinks; + } + + /// The portals in this document, with the nodes they sit on. + pub fn portals(&self) -> impl Iterator + '_ { + self.entries.iter().filter_map(|(id, entry)| match &entry.node.content { + Content::Portal(portal) => Some((*id, portal)), + Content::Idea(_) => None, + }) + } +} + +#[cfg(test)] +impl Document { + /// Panics if one of the document's rules is broken. Tests call this + /// after edits. + pub(crate) fn assert_rules(&self) { + let roots: Vec<_> = + self.entries.iter().filter(|(_, e)| e.place == Place::Root).map(|(id, _)| *id).collect(); + assert_eq!(roots, vec![self.root], "exactly one root"); + + let loose: Vec<_> = + self.entries.iter().filter(|(_, e)| e.place == Place::Loose).map(|(id, _)| *id).collect(); + let mut listed = self.loose.clone(); + listed.sort(); + assert_eq!(listed, loose, "the loose list matches the loose nodes"); + + for (id, entry) in &self.entries { + if let Place::Child { parent, .. } = &entry.place { + let siblings = self.children(*parent); + assert_eq!( + siblings.iter().filter(|&&s| s == *id).count(), + 1, + "{id} is listed once by its parent" + ); + } + for child in &entry.children { + assert_eq!(self.parent(*child), Some(*id), "{child} knows its parent {id}"); + } + assert!(id.get() < self.next_id, "{id} is below next_id"); + } + + let mut outline = self.outline(); + assert_eq!(outline.len(), self.entries.len(), "every node is reachable once (no cycles)"); + outline.sort(); + outline.dedup(); + assert_eq!(outline.len(), self.entries.len(), "no node is reached twice"); + + for (key, link) in &self.links { + assert_eq!(*key, link.key()); + assert!(self.contains(link.start()) && self.contains(link.end()), "link ends exist"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::{garden, idea, title}; + + fn record(id: u32, parent: Option, name: &str) -> NodeRecord { + NodeRecord { + id: NodeId::new(id), + parent: parent.map(NodeId::new), + branch: LineLook::default(), + node: idea(name), + } + } + + fn build(records: Vec) -> Result { + Document::from_records( + DocUid::parse("0000000000000001").unwrap(), + NodeId::new(1), + records, + vec![], + vec![], + ) + } + + #[test] + fn records_become_a_tree_in_file_order() { + let doc = build(vec![ + record(1, None, "root"), + record(3, Some(1), "b"), + record(2, Some(1), "a"), + record(9, None, "loose"), + record(4, Some(3), "b1"), + ]) + .unwrap(); + doc.assert_rules(); + let ids = |raw: &[u32]| raw.iter().map(|&n| NodeId::new(n)).collect::>(); + assert_eq!(doc.children(NodeId::new(1)), ids(&[3, 2]).as_slice()); + assert_eq!(doc.loose(), ids(&[9]).as_slice()); + assert_eq!(doc.outline(), ids(&[1, 3, 4, 2, 9])); + assert_eq!(doc.title().as_str(), "root"); + } + + #[test] + fn broken_records_are_refused() { + use StructureError as E; + let n = NodeId::new; + assert_eq!(build(vec![record(2, None, "x")]), Err(E::MissingRoot(n(1)))); + assert_eq!(build(vec![record(1, Some(2), "r"), record(2, None, "x")]), Err(E::RootWithParent(n(1)))); + assert_eq!(build(vec![record(1, None, "r"), record(1, None, "again")]), Err(E::DuplicateNode(n(1)))); + assert_eq!( + build(vec![record(1, None, "r"), record(2, Some(5), "x")]), + Err(E::UnknownParent { child: n(2), parent: n(5) }) + ); + assert!(matches!( + build(vec![record(1, None, "r"), record(2, Some(3), "x"), record(3, Some(2), "y")]), + Err(E::Cycle(_)) + )); + } + + #[test] + fn siblings_ancestors_and_branches() { + let (doc, g) = garden(); + assert_eq!(doc.next_sibling(g.soil), Some(g.beds)); + assert_eq!(doc.previous_sibling(g.soil), None); + assert_eq!( + doc.next_sibling(g.root), + Some(g.compost), + "the root's next sibling is the first loose node" + ); + assert_eq!(doc.ancestors(g.cedar).collect::>(), vec![g.beds, g.root]); + assert!(doc.is_inside(g.cedar, g.root) && !doc.is_inside(g.root, g.cedar)); + assert_eq!(doc.depth(g.cedar), 2); + assert_eq!(doc.branch(g.beds), vec![g.beds, g.cedar]); + assert_eq!(doc.outline(), vec![g.root, g.soil, g.beds, g.cedar, g.watering, g.compost]); + assert_eq!(Document::new(title("x")).node_count(), 1); + } +} diff --git a/src/model/edit.rs b/src/model/edit.rs new file mode 100644 index 0000000..dce4b68 --- /dev/null +++ b/src/model/edit.rs @@ -0,0 +1,558 @@ +//! Changing a document. Every change runs inside [`Document::edit`] as one +//! transaction: if any step fails, the document goes back to exactly how it +//! was, and if all goes well you get a [`Change`] that undo can use. +//! +//! A transaction remembers the old value of everything it touches, the first +//! time it touches it. Undo writes those old values back and redo the new +//! ones, so no edit needs its own "reverse" code. + +use std::collections::BTreeMap; + +use thiserror::Error; + +use super::document::{Document, Entry, Place}; +use super::geometry::CanvasOffset; +use super::ids::NodeId; +use super::link::{Link, LinkKey}; +use super::look::LineLook; +use super::node::Node; +use super::text::Label; + +/// Where a node goes in the tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Spot { + /// After the other children of a node. + LastChildOf(NodeId), + /// Before the other children of a node. + FirstChildOf(NodeId), + /// Right after a node, among its siblings. Right after the root is the + /// first loose node. + After(NodeId), + /// Right before a node, among its siblings. Nothing goes before the root. + Before(NodeId), + /// A loose node, after the other loose nodes. + Loose, +} + +/// Why an edit was refused. The document is left exactly as it was. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum EditError { + #[error("node {0} doesn't exist")] + NoSuchNode(NodeId), + #[error("the root can't be deleted")] + RemoveRoot, + #[error("the root stays at the top")] + MoveRoot, + #[error("nothing goes before the root")] + BeforeRoot, + #[error("a branch can't go inside itself")] + IntoOwnBranch, + #[error("node {0} has no parent, so it has no branch line")] + NotAChild(NodeId), + #[error("nodes {0} and {1} aren't linked")] + NotLinked(NodeId, NodeId), + #[error("this document can't hold any more nodes")] + Full, +} + +/// One undo step: what an edit touched, before and after. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Change { + before: Snapshot, + after: Snapshot, +} + +/// Some parts of a document as they were at one moment. `None` means the +/// node or link didn't exist then. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct Snapshot { + entries: BTreeMap>, + links: BTreeMap>, + /// The list of loose nodes, if the edit changed it. + loose: Option>, +} + +impl Change { + /// True when nothing actually changed, so it isn't worth an undo step. + pub fn is_empty(&self) -> bool { + self.before == self.after + } + + /// The nodes this change touched, so undo and redo can show where it + /// happened. + pub fn nodes(&self) -> impl Iterator + '_ { + self.before.entries.keys().copied() + } + + pub(super) fn before(&self) -> &Snapshot { + &self.before + } + + pub(super) fn after(&self) -> &Snapshot { + &self.after + } +} + +impl Document { + /// Changes the document in one transaction. `steps` gets an [`Edit`] to + /// work with. If it returns an error, the document is put back exactly as + /// it was; otherwise you get its result and the [`Change`] for undo. + /// + /// ``` + /// use cumulus::model::{CanvasPos, Document, History, Node, Spot, Title}; + /// + /// let mut doc = Document::new(Title::parse("Garden plans").unwrap()); + /// let mut history = History::default(); + /// let root = doc.root(); + /// let soil = Node::idea(Title::parse("Soil").unwrap(), CanvasPos::new(22, 0)); + /// + /// let (id, change) = doc.edit(|edit| edit.add(soil, Spot::LastChildOf(root))).unwrap(); + /// history.record(change); + /// assert_eq!(doc.children(root), &[id]); + /// + /// history.undo(&mut doc); + /// assert!(doc.children(root).is_empty()); + /// ``` + pub fn edit( + &mut self, + steps: impl FnOnce(&mut Edit<'_>) -> Result, + ) -> Result<(T, Change), EditError> { + let mut edit = Edit { doc: self, before: Snapshot::default() }; + match steps(&mut edit) { + Ok(value) => Ok((value, edit.finish())), + Err(error) => { + edit.roll_back(); + Err(error) + } + } + } + + /// Writes the values kept in `snapshot` back (for undo and redo). + pub(super) fn restore(&mut self, snapshot: &Snapshot) { + for (id, entry) in &snapshot.entries { + match entry { + Some(entry) => self.entries.insert(*id, entry.clone()), + None => self.entries.remove(id), + }; + } + for (key, link) in &snapshot.links { + match link { + Some(link) => self.links.insert(*key, link.clone()), + None => self.links.remove(key), + }; + } + if let Some(loose) = &snapshot.loose { + self.loose = loose.clone(); + } + } +} + +/// A transaction in progress (see [`Document::edit`]): the document, plus the +/// old value of everything changed so far. +pub struct Edit<'a> { + doc: &'a mut Document, + before: Snapshot, +} + +impl Edit<'_> { + /// The document as it is now, partway through the edit. + pub fn doc(&self) -> &Document { + self.doc + } + + /// Adds a node at `spot` and returns its new id. + pub fn add(&mut self, node: Node, spot: Spot) -> Result { + let (parent, index) = self.resolve(spot)?; + let id = NodeId::new(self.doc.next_id); + self.doc.next_id = self.doc.next_id.checked_add(1).ok_or(EditError::Full)?; + self.remember(id); + let place = match parent { + Some(parent) => Place::Child { parent, branch: LineLook::default() }, + None => Place::Loose, + }; + self.doc.entries.insert(id, Entry { node, place, children: Vec::new() }); + self.insert_into(id, parent, index); + Ok(id) + } + + /// Moves a node, with its whole branch, to `spot` in the tree. Its + /// position on the canvas doesn't change. + pub fn move_to(&mut self, id: NodeId, spot: Spot) -> Result<(), EditError> { + self.require(id)?; + if id == self.doc.root() { + return Err(EditError::MoveRoot); + } + if let Spot::After(anchor) | Spot::Before(anchor) = spot + && anchor == id + { + return Ok(()); + } + let new_parent = self.parent_at(spot)?; + if new_parent.is_some_and(|parent| parent == id || self.doc.is_inside(parent, id)) { + return Err(EditError::IntoOwnBranch); + } + // A branch line keeps its look when the node moves to another parent. + let branch = match self.doc.place(id) { + Some(Place::Child { branch, .. }) => branch.clone(), + _ => LineLook::default(), + }; + self.take_out(id); + let (parent, index) = self.resolve(spot)?; + self.set_place(id, parent, branch); + self.insert_into(id, parent, index); + Ok(()) + } + + /// Removes a node with its whole branch, and every link touching them. + /// Returns the removed nodes, the given one first. + pub fn remove_branch(&mut self, id: NodeId) -> Result, EditError> { + self.require(id)?; + if id == self.doc.root() { + return Err(EditError::RemoveRoot); + } + let removed = self.doc.branch(id); + self.take_out(id); + for &node in &removed { + self.remember(node); + self.doc.entries.remove(&node); + } + self.remove_links_touching(&removed); + Ok(removed) + } + + /// Removes just this node: its children take its place, in order. Links + /// touching it are removed too. + pub fn remove_keeping_children(&mut self, id: NodeId) -> Result<(), EditError> { + self.require(id)?; + if id == self.doc.root() { + return Err(EditError::RemoveRoot); + } + let parent = self.doc.parent(id); + let index = self.index_among_siblings(id); + let children = self.doc.children(id).to_vec(); + self.take_out(id); + for (offset, &child) in children.iter().enumerate() { + let branch = match self.doc.place(child) { + Some(Place::Child { branch, .. }) => branch.clone(), + _ => LineLook::default(), + }; + self.set_place(child, parent, branch); + self.insert_into(child, parent, index + offset); + } + self.remember(id); + self.doc.entries.remove(&id); + self.remove_links_touching(&[id]); + Ok(()) + } + + /// Changes a node's content, tags, look, position or fold. + pub fn update(&mut self, id: NodeId, change: impl FnOnce(&mut Node)) -> Result<(), EditError> { + self.require(id)?; + self.remember(id); + if let Some(entry) = self.doc.entries.get_mut(&id) { + change(&mut entry.node); + } + Ok(()) + } + + /// Moves a node and everything below it on the canvas. + pub fn shift_branch(&mut self, id: NodeId, offset: CanvasOffset) -> Result<(), EditError> { + self.require(id)?; + for node in self.doc.branch(id) { + self.update(node, |node| node.pos += offset)?; + } + Ok(()) + } + + /// Sets how the line from a node's parent is drawn. + pub fn set_branch_look(&mut self, id: NodeId, look: LineLook) -> Result<(), EditError> { + self.require(id)?; + let Some(Place::Child { parent, .. }) = self.doc.place(id) else { + return Err(EditError::NotAChild(id)); + }; + let parent = *parent; + self.set_place(id, Some(parent), look); + Ok(()) + } + + /// Links two nodes, replacing any link already between them. + pub fn link(&mut self, link: Link) -> Result { + self.require(link.start())?; + self.require(link.end())?; + let key = link.key(); + self.remember_link(key); + self.doc.links.insert(key, link); + Ok(key) + } + + /// Removes the link between two nodes, and returns it. + pub fn unlink(&mut self, key: LinkKey) -> Result { + self.link_mut(key)?; + let (a, b) = key.nodes(); + self.doc.links.remove(&key).ok_or(EditError::NotLinked(a, b)) + } + + /// Sets or clears the label of a link. + pub fn set_link_label(&mut self, key: LinkKey, label: Option