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