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 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 00:54:49 -04:00
1 parent 0e03c396bf
commit 69aa7eee58
17 files changed
+2832

No files matched your search

Generated
+125
View File
@@ -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"
+7
View File
@@ -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"
+8
View File
@@ -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`).
+492
View File
@@ -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<NodeId>,
}
/// 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<NodeId, Entry>,
/// The loose nodes, in order.
pub(super) loose: Vec<NodeId>,
pub(super) links: BTreeMap<LinkKey, Link>,
backlinks: Vec<Backlink>,
/// 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<NodeId>,
/// 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<NodeRecord>,
links: Vec<Link>,
backlinks: Vec<Backlink>,
) -> Result<Document, StructureError> {
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(&current).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<NodeId> {
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<Item = NodeId> + '_ {
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<NodeId> {
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<NodeId> {
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<NodeId>, usize)> {
let siblings: Vec<NodeId> = 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<Item = NodeId> + '_ {
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<NodeId> {
let mut found = Vec::new();
let mut to_visit: Vec<NodeId> = 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<NodeId> {
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<NodeId> {
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<Item = &Link> {
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<Item = &Link> + '_ {
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<Backlink>) {
self.backlinks = backlinks;
}
/// The portals in this document, with the nodes they sit on.
pub fn portals(&self) -> impl Iterator<Item = (NodeId, &Portal)> + '_ {
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<u32>, name: &str) -> NodeRecord {
NodeRecord {
id: NodeId::new(id),
parent: parent.map(NodeId::new),
branch: LineLook::default(),
node: idea(name),
}
}
fn build(records: Vec<NodeRecord>) -> Result<Document, StructureError> {
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::<Vec<_>>();
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<_>>(), 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);
}
}
+558
View File
@@ -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<NodeId, Option<Entry>>,
links: BTreeMap<LinkKey, Option<Link>>,
/// The list of loose nodes, if the edit changed it.
loose: Option<Vec<NodeId>>,
}
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<Item = NodeId> + '_ {
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<T>(
&mut self,
steps: impl FnOnce(&mut Edit<'_>) -> Result<T, EditError>,
) -> 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<NodeId, EditError> {
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<Vec<NodeId>, 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<LinkKey, EditError> {
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<Link, EditError> {
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<Label>) -> Result<(), EditError> {
self.link_mut(key)?.label = label;
Ok(())
}
/// Sets how a link is drawn.
pub fn set_link_look(&mut self, key: LinkKey, look: LineLook) -> Result<(), EditError> {
self.link_mut(key)?.look = look;
Ok(())
}
/// Makes a link point the other way.
pub fn reverse_link(&mut self, key: LinkKey) -> Result<(), EditError> {
let link = self.link_mut(key)?;
*link = link.clone().reversed();
Ok(())
}
// ----- Helpers -------------------------------------------------------
fn require(&self, id: NodeId) -> Result<(), EditError> {
if self.doc.contains(id) { Ok(()) } else { Err(EditError::NoSuchNode(id)) }
}
/// Keeps a node's current value (or its absence) for undo, the first time
/// it's touched.
fn remember(&mut self, id: NodeId) {
self.before.entries.entry(id).or_insert_with(|| self.doc.entries.get(&id).cloned());
}
fn remember_link(&mut self, key: LinkKey) {
self.before.links.entry(key).or_insert_with(|| self.doc.links.get(&key).cloned());
}
fn remember_loose(&mut self) {
if self.before.loose.is_none() {
self.before.loose = Some(self.doc.loose.clone());
}
}
fn link_mut(&mut self, key: LinkKey) -> Result<&mut Link, EditError> {
let (a, b) = key.nodes();
if !self.doc.links.contains_key(&key) {
return Err(EditError::NotLinked(a, b));
}
self.remember_link(key);
self.doc.links.get_mut(&key).ok_or(EditError::NotLinked(a, b))
}
/// The parent a node at `spot` would have (`None` for the top level).
fn parent_at(&self, spot: Spot) -> Result<Option<NodeId>, EditError> {
match spot {
Spot::LastChildOf(parent) | Spot::FirstChildOf(parent) => {
self.require(parent)?;
Ok(Some(parent))
}
Spot::After(anchor) | Spot::Before(anchor) => {
self.require(anchor)?;
if anchor == self.doc.root() && matches!(spot, Spot::Before(_)) {
return Err(EditError::BeforeRoot);
}
Ok(self.doc.parent(anchor))
}
Spot::Loose => Ok(None),
}
}
/// The parent (or the top level) and the index where a node at `spot`
/// goes, given the tree as it is now.
fn resolve(&self, spot: Spot) -> Result<(Option<NodeId>, usize), EditError> {
let parent = self.parent_at(spot)?;
let index = match spot {
Spot::LastChildOf(parent) => self.doc.children(parent).len(),
Spot::FirstChildOf(_) => 0,
// Right after the root: the first loose node.
Spot::After(anchor) if anchor == self.doc.root() => 0,
Spot::After(anchor) => self.index_among_siblings(anchor) + 1,
Spot::Before(anchor) => self.index_among_siblings(anchor),
Spot::Loose => self.doc.loose.len(),
};
Ok((parent, index))
}
/// A node's index in its parent's children, or among the loose nodes.
fn index_among_siblings(&self, id: NodeId) -> usize {
let siblings = match self.doc.parent(id) {
Some(parent) => self.doc.children(parent),
None => &self.doc.loose,
};
siblings.iter().position(|&sibling| sibling == id).unwrap_or(0)
}
/// Removes a node from its parent's children, or from the loose nodes.
fn take_out(&mut self, id: NodeId) {
match self.doc.parent(id) {
Some(parent) => {
self.remember(parent);
if let Some(entry) = self.doc.entries.get_mut(&parent) {
entry.children.retain(|&child| child != id);
}
}
None => {
self.remember_loose();
self.doc.loose.retain(|&node| node != id);
}
}
}
/// Puts a node into its parent's children, or into the loose nodes.
fn insert_into(&mut self, id: NodeId, parent: Option<NodeId>, index: usize) {
match parent {
Some(parent) => {
self.remember(parent);
if let Some(entry) = self.doc.entries.get_mut(&parent) {
entry.children.insert(index.min(entry.children.len()), id);
}
}
None => {
self.remember_loose();
self.doc.loose.insert(index.min(self.doc.loose.len()), id);
}
}
}
/// Records that a node is now under `parent` (or loose, without one).
fn set_place(&mut self, id: NodeId, parent: Option<NodeId>, branch: LineLook) {
self.remember(id);
if let Some(entry) = self.doc.entries.get_mut(&id) {
entry.place = match parent {
Some(parent) => Place::Child { parent, branch },
None => Place::Loose,
};
}
}
fn remove_links_touching(&mut self, nodes: &[NodeId]) {
let keys: Vec<LinkKey> = self
.doc
.links
.keys()
.filter(|key| nodes.iter().any(|&node| key.touches(node)))
.copied()
.collect();
for key in keys {
self.remember_link(key);
self.doc.links.remove(&key);
}
}
/// The old values, and the new ones for the same parts: the undo step.
fn finish(self) -> Change {
let after = Snapshot {
entries: self.before.entries.keys().map(|id| (*id, self.doc.entries.get(id).cloned())).collect(),
links: self.before.links.keys().map(|key| (*key, self.doc.links.get(key).cloned())).collect(),
loose: self.before.loose.as_ref().map(|_| self.doc.loose.clone()),
};
Change { before: self.before, after }
}
fn roll_back(self) {
self.doc.restore(&self.before);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::test_support::{garden, idea};
use crate::model::{CanvasPos, Fold};
#[test]
fn adding_and_moving_keep_sibling_order() {
let (mut doc, g) = garden();
let (new, _) = doc.edit(|e| e.add(idea("new"), Spot::Before(g.beds))).unwrap();
assert_eq!(doc.children(g.root), &[g.soil, new, g.beds, g.watering]);
doc.edit(|e| e.move_to(g.soil, Spot::After(g.watering))).unwrap();
assert_eq!(doc.children(g.root), &[new, g.beds, g.watering, g.soil]);
doc.edit(|e| e.move_to(g.beds, Spot::Loose)).unwrap();
assert_eq!(doc.loose(), &[g.compost, g.beds]);
assert_eq!(doc.children(g.beds), &[g.cedar], "a branch moves whole");
doc.edit(|e| e.add(idea("first loose"), Spot::After(g.root))).unwrap();
assert_eq!(doc.loose().len(), 3);
doc.assert_rules();
}
#[test]
fn the_root_and_the_tree_shape_are_protected() {
let (mut doc, g) = garden();
let before = doc.clone();
assert_eq!(doc.edit(|e| e.remove_branch(g.root)), Err(EditError::RemoveRoot));
assert_eq!(doc.edit(|e| e.move_to(g.root, Spot::Loose)), Err(EditError::MoveRoot));
assert_eq!(doc.edit(|e| e.add(idea("x"), Spot::Before(g.root))), Err(EditError::BeforeRoot));
assert_eq!(
doc.edit(|e| e.move_to(g.beds, Spot::LastChildOf(g.cedar))),
Err(EditError::IntoOwnBranch)
);
assert_eq!(doc.edit(|e| e.move_to(g.beds, Spot::After(g.cedar))), Err(EditError::IntoOwnBranch));
assert_eq!(doc, before);
}
#[test]
fn a_failed_edit_leaves_no_trace() {
let (mut doc, g) = garden();
let before = doc.clone();
let result = doc.edit(|e| {
e.update(g.soil, |node| node.fold = Fold::Closed)?;
e.remove_branch(g.beds)?;
e.remove_branch(NodeId::new(999))
});
assert_eq!(result, Err(EditError::NoSuchNode(NodeId::new(999))));
assert_eq!(doc, before);
}
#[test]
fn removing_keeping_children_lifts_them_in_place() {
let (mut doc, g) = garden();
let (grandchild, _) = doc.edit(|e| e.add(idea("cedar 2"), Spot::LastChildOf(g.beds))).unwrap();
doc.edit(|e| e.link(Link::new(g.beds, g.soil).unwrap())).unwrap();
doc.edit(|e| e.remove_keeping_children(g.beds)).unwrap();
assert_eq!(doc.children(g.root), &[g.soil, g.cedar, grandchild, g.watering]);
assert_eq!(doc.links().count(), 0, "links to the removed node go too");
doc.assert_rules();
}
#[test]
fn removing_a_branch_removes_its_links() {
let (mut doc, g) = garden();
doc.edit(|e| e.link(Link::new(g.cedar, g.compost).unwrap())).unwrap();
doc.edit(|e| e.link(Link::new(g.soil, g.compost).unwrap())).unwrap();
let (removed, _) = doc.edit(|e| e.remove_branch(g.beds)).unwrap();
assert_eq!(removed, vec![g.beds, g.cedar]);
assert_eq!(doc.links().count(), 1);
doc.assert_rules();
}
#[test]
fn shifting_a_branch_moves_every_node_in_it() {
let (mut doc, g) = garden();
doc.edit(|e| e.shift_branch(g.beds, CanvasOffset::new(5, -1))).unwrap();
let pos = |id| doc.node(id).unwrap().pos;
assert_eq!(pos(g.beds), CanvasPos::new(27, 3));
assert_eq!(pos(g.cedar), CanvasPos::new(45, 3));
assert_eq!(pos(g.soil), CanvasPos::new(22, 0), "outside the branch: untouched");
}
#[test]
fn branch_looks_belong_to_children() {
let (mut doc, g) = garden();
let look = LineLook { arrows: Some(crate::model::Arrows::End), ..LineLook::default() };
doc.edit(|e| e.set_branch_look(g.cedar, look.clone())).unwrap();
assert_eq!(doc.place(g.cedar), Some(&Place::Child { parent: g.beds, branch: look.clone() }));
doc.edit(|e| e.move_to(g.cedar, Spot::LastChildOf(g.soil))).unwrap();
assert_eq!(
doc.place(g.cedar),
Some(&Place::Child { parent: g.soil, branch: look }),
"kept on a move"
);
assert_eq!(
doc.edit(|e| e.set_branch_look(g.compost, LineLook::default())),
Err(EditError::NotAChild(g.compost))
);
}
}
+105
View File
@@ -0,0 +1,105 @@
//! Which nodes are on show, given how each branch is folded and where the
//! cursor is. Every view asks this, so they all fold the same way.
use std::collections::{BTreeMap, BTreeSet};
use super::document::Document;
use super::ids::NodeId;
use super::node::Fold;
/// The nodes on show, and what's hidden.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Visible {
/// The nodes on show, in reading order.
pub order: Vec<NodeId>,
/// For each node whose children are hidden: how many nodes its branch
/// hides (the `+3` badge).
pub hidden: BTreeMap<NodeId, usize>,
shown: BTreeSet<NodeId>,
}
impl Visible {
/// Works out what's on show:
/// - an Open branch shows its children (with `focus` on, it acts like
/// Auto instead);
/// - an Auto branch shows them while the cursor is on the node or inside
/// its branch;
/// - a Closed branch shows them only while the cursor is inside it.
pub fn new(doc: &Document, cursor: NodeId, focus: bool) -> Visible {
// The cursor is inside the branches of all its ancestors.
let around_cursor: BTreeSet<NodeId> = doc.ancestors(cursor).collect();
let mut visible = Visible::default();
let mut to_visit: Vec<NodeId> = doc.top_level().collect();
to_visit.reverse();
while let Some(id) = to_visit.pop() {
visible.order.push(id);
visible.shown.insert(id);
let children = doc.children(id);
let Some(node) = doc.node(id) else { continue };
if children.is_empty() {
continue;
}
let inside = around_cursor.contains(&id);
let open = match node.fold {
Fold::Open if !focus => true,
Fold::Open | Fold::Auto => inside || id == cursor,
Fold::Closed => inside,
};
if open {
to_visit.extend(children.iter().rev());
} else {
visible.hidden.insert(id, doc.descendants(id).len());
}
}
visible
}
/// Whether a node is on show.
pub fn contains(&self, id: NodeId) -> bool {
self.shown.contains(&id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Spot;
use crate::model::test_support::{garden, idea};
#[test]
fn open_auto_and_closed_branches() {
let (mut doc, g) = garden();
// root ─ soil, beds (Auto) ─ cedar, watering (Closed) ─ hose ─ nozzle
let ((hose, nozzle), _) = doc
.edit(|e| {
e.update(g.beds, |node| node.fold = Fold::Auto)?;
e.update(g.watering, |node| node.fold = Fold::Closed)?;
let hose = e.add(idea("hose"), Spot::LastChildOf(g.watering))?;
let nozzle = e.add(idea("nozzle"), Spot::LastChildOf(hose))?;
Ok((hose, nozzle))
})
.unwrap();
let at_root = Visible::new(&doc, g.root, false);
assert_eq!(at_root.order, vec![g.root, g.soil, g.beds, g.watering, g.compost]);
assert_eq!(at_root.hidden, BTreeMap::from([(g.beds, 1), (g.watering, 2)]));
assert!(Visible::new(&doc, g.beds, false).contains(g.cedar), "Auto opens on the node");
assert!(Visible::new(&doc, g.cedar, false).contains(g.cedar), "and while inside");
assert!(!Visible::new(&doc, g.watering, false).contains(hose), "Closed stays shut on the node");
let jumped_in = Visible::new(&doc, nozzle, false);
assert!(jumped_in.contains(hose) && jumped_in.contains(nozzle), "a jump inside opens the way");
}
#[test]
fn focus_makes_open_branches_follow_the_cursor() {
let (doc, g) = garden();
let focused = Visible::new(&doc, g.soil, true);
assert!(focused.contains(g.soil), "the cursor's own path stays open");
assert!(!focused.contains(g.cedar), "other branches fold away");
assert_eq!(focused.hidden.get(&g.beds), Some(&1));
assert!(Visible::new(&doc, g.soil, false).contains(g.cedar), "without focus, Open is open");
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Places on the canvas.
//!
//! The canvas is a grid of terminal cells (at Normal zoom) with no edges, so
//! coordinates can be negative. `x` grows to the right and `y` downward, as in
//! the terminal.
use std::ops::{Add, AddAssign, Neg, Sub};
/// A point on the canvas.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct CanvasPos {
pub x: i32,
pub y: i32,
}
impl CanvasPos {
/// The point at `x`, `y`.
pub const fn new(x: i32, y: i32) -> CanvasPos {
CanvasPos { x, y }
}
}
/// A movement on the canvas: `dx` cells to the right, `dy` cells down.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct CanvasOffset {
pub dx: i32,
pub dy: i32,
}
impl CanvasOffset {
/// A movement of `dx` cells to the right and `dy` cells down.
pub const fn new(dx: i32, dy: i32) -> CanvasOffset {
CanvasOffset { dx, dy }
}
}
impl Add<CanvasOffset> for CanvasPos {
type Output = CanvasPos;
fn add(self, offset: CanvasOffset) -> CanvasPos {
CanvasPos::new(self.x + offset.dx, self.y + offset.dy)
}
}
impl AddAssign<CanvasOffset> for CanvasPos {
fn add_assign(&mut self, offset: CanvasOffset) {
*self = *self + offset;
}
}
impl Sub for CanvasPos {
type Output = CanvasOffset;
/// The movement that goes from `other` to `self`.
fn sub(self, other: CanvasPos) -> CanvasOffset {
CanvasOffset::new(self.x - other.x, self.y - other.y)
}
}
impl Neg for CanvasOffset {
type Output = CanvasOffset;
fn neg(self) -> CanvasOffset {
CanvasOffset::new(-self.dx, -self.dy)
}
}
+148
View File
@@ -0,0 +1,148 @@
//! Undo and redo, for one document.
use super::document::Document;
use super::edit::Change;
/// One state of a document along its history. Each change makes a new state;
/// undo and redo move between them. A buffer remembers the state it last
/// saved, to know whether anything is unsaved.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct StateId(u64);
/// The undo history of one document: the changes that can be undone (newest
/// last), and the ones undone that can be redone until the next new change.
#[derive(Debug, Default)]
pub struct History {
done: Vec<(StateId, Change)>,
undone: Vec<(StateId, Change)>,
last_state: u64,
}
impl History {
/// Remembers a change that was just made. Changes that changed nothing
/// are skipped, and a new change means what was undone can't be redone
/// anymore.
pub fn record(&mut self, change: Change) {
if change.is_empty() {
return;
}
self.last_state += 1;
self.done.push((StateId(self.last_state), change));
self.undone.clear();
}
/// Undoes the newest change, and returns it so the caller can show where
/// it happened.
pub fn undo(&mut self, doc: &mut Document) -> Option<&Change> {
let (state, change) = self.done.pop()?;
doc.restore(change.before());
self.undone.push((state, change));
self.undone.last().map(|(_, change)| change)
}
/// Redoes the newest undone change, and returns it.
pub fn redo(&mut self, doc: &mut Document) -> Option<&Change> {
let (state, change) = self.undone.pop()?;
doc.restore(change.after());
self.done.push((state, change));
self.done.last().map(|(_, change)| change)
}
/// The state the document is in now.
pub fn state(&self) -> StateId {
self.done.last().map_or(StateId::default(), |(state, _)| *state)
}
/// Whether there's a change to undo.
pub fn can_undo(&self) -> bool {
!self.done.is_empty()
}
/// Whether there's an undone change to redo.
pub fn can_redo(&self) -> bool {
!self.undone.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::test_support::{idea, title};
use crate::model::{DocUid, Fold, Link, NodeId, Spot};
/// A small, predictable random number generator (xorshift), so the test
/// tries many edits but always the same ones.
struct Dice(u64);
impl Dice {
fn roll(&mut self, sides: usize) -> usize {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 % sides as u64) as usize
}
}
#[test]
fn undo_and_redo_retrace_every_step() {
let mut dice = Dice(0x2545_f491_4f6c_dd1d);
let mut doc = Document::with_uid(title("root"), DocUid::parse("00000000000000aa").unwrap());
let mut history = History::default();
let mut states = vec![doc.clone()];
for step in 0..500 {
let nodes = doc.outline();
let a = nodes[dice.roll(nodes.len())];
let b = nodes[dice.roll(nodes.len())];
let kind = dice.roll(8);
let result = doc.edit(|e| match kind {
0 | 1 => e.add(idea(&format!("n{step}")), Spot::LastChildOf(a)).map(drop),
2 => e.add(idea("loose"), Spot::Loose).map(drop),
3 => e.move_to(a, Spot::After(b)),
4 => e.remove_branch(a).map(drop),
5 => e.remove_keeping_children(a),
6 => Link::new(a, b).map_or(Ok(()), |link| e.link(link).map(drop)),
_ => e.update(a, |node| node.fold = Fold::Closed),
});
doc.assert_rules();
match result {
Ok(((), change)) if !change.is_empty() => {
history.record(change);
states.push(doc.clone());
}
_ => assert_eq!(&doc, states.last().unwrap(), "a refused or empty edit changes nothing"),
}
}
assert!(states.len() > 100, "most edits went through");
for expected in states.iter().rev().skip(1) {
assert!(history.undo(&mut doc).is_some());
assert_eq!(&doc, expected);
doc.assert_rules();
}
assert!(history.undo(&mut doc).is_none());
for expected in states.iter().skip(1) {
assert!(history.redo(&mut doc).is_some());
assert_eq!(&doc, expected);
}
}
#[test]
fn states_tell_saved_from_unsaved() {
let mut doc = Document::new(title("root"));
let mut history = History::default();
let saved = history.state();
let (_, change) = doc.edit(|e| e.add(idea("a"), Spot::LastChildOf(doc_root()))).unwrap();
history.record(change);
assert_ne!(history.state(), saved);
history.undo(&mut doc);
assert_eq!(history.state(), saved, "back where it was saved");
let (_, change) = doc.edit(|e| e.add(idea("b"), Spot::Loose)).unwrap();
history.record(change);
assert!(!history.can_redo(), "a new change drops what was undone");
}
fn doc_root() -> NodeId {
NodeId::new(1)
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Identifiers. Each is its own type, so a node id can't be passed where
//! something else is expected.
use std::fmt;
use std::hash::{BuildHasher, Hasher, RandomState};
use std::time::{SystemTime, UNIX_EPOCH};
/// A node inside one document. A document never hands out the same id twice
/// while it's open, so an id kept in an undo step or a link always means the
/// same node.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NodeId(u32);
impl NodeId {
/// The id with this number, as written in document files.
pub const fn new(number: u32) -> NodeId {
NodeId(number)
}
/// The number, as written in document files.
pub const fn get(self) -> u32 {
self.0
}
}
impl fmt::Display for NodeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// A document's identity, written in its file. It stays the same when the
/// file is renamed or moved, so cumulus recognizes the document anywhere.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DocUid(u64);
impl DocUid {
/// A new random uid, for a new document.
pub fn random() -> DocUid {
// `RandomState` starts from a random seed the OS provides. Mixing in
// the time and the process id keeps two uids made in the same
// instant, in two programs, apart too.
let mut hasher = RandomState::new().build_hasher();
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_nanos());
hasher.write_u128(nanos);
hasher.write_u32(std::process::id());
DocUid(hasher.finish())
}
/// Reads a uid written as 16 hexadecimal digits, as in document files.
pub fn parse(text: &str) -> Option<DocUid> {
if text.len() != 16 || !text.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
u64::from_str_radix(text, 16).ok().map(DocUid)
}
}
impl fmt::Display for DocUid {
/// 16 hexadecimal digits, as in document files.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:016x}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uids_are_written_as_16_hex_digits_and_read_back() {
let uid = DocUid::random();
let text = uid.to_string();
assert_eq!(text.len(), 16);
assert_eq!(DocUid::parse(&text), Some(uid));
assert_eq!(
DocUid::parse("00000000000000FF").map(|uid| uid.to_string()),
Some("00000000000000ff".into())
);
for bad in ["", "123", "+00000000000000f", "0000000000000000f", "zzzzzzzzzzzzzzzz"] {
assert_eq!(DocUid::parse(bad), None, "{bad:?}");
}
}
#[test]
fn random_uids_differ() {
assert_ne!(DocUid::random(), DocUid::random());
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Cross-links: lines between any two nodes, besides the branches of the
//! tree.
use super::ids::NodeId;
use super::look::LineLook;
use super::text::Label;
/// Identifies the link between two nodes. Two nodes have at most one link
/// between them, whichever way it points, so the key is just the pair (the
/// smaller id first).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LinkKey {
low: NodeId,
high: NodeId,
}
impl LinkKey {
/// The key for the link between `a` and `b`, or `None` when they're the
/// same node (a node can't link to itself).
pub fn new(a: NodeId, b: NodeId) -> Option<LinkKey> {
if a == b {
return None;
}
Some(LinkKey { low: a.min(b), high: a.max(b) })
}
/// Both nodes, the smaller id first.
pub fn nodes(self) -> (NodeId, NodeId) {
(self.low, self.high)
}
/// Whether `node` is one of the two ends.
pub fn touches(self, node: NodeId) -> bool {
self.low == node || self.high == node
}
}
/// A cross-link between two nodes. It points from its start to its end, which
/// only matters for where the arrowheads go.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
key: LinkKey,
/// True when the link points from the higher id to the lower one.
downward: bool,
/// Text shown in the middle of the line.
pub label: Option<Label>,
/// How the line is drawn.
pub look: LineLook,
}
impl Link {
/// A link from `start` to `end` with the theme's look, or `None` when
/// they're the same node.
pub fn new(start: NodeId, end: NodeId) -> Option<Link> {
let key = LinkKey::new(start, end)?;
Some(Link { key, downward: start > end, label: None, look: LineLook::default() })
}
/// The node the link starts from.
pub fn start(&self) -> NodeId {
if self.downward { self.key.high } else { self.key.low }
}
/// The node the link points to.
pub fn end(&self) -> NodeId {
if self.downward { self.key.low } else { self.key.high }
}
/// The pair of nodes it joins.
pub fn key(&self) -> LinkKey {
self.key
}
/// The same link, pointing the other way.
pub fn reversed(self) -> Link {
Link { downward: !self.downward, ..self }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_link_keeps_its_direction_and_its_key_ignores_it() {
let (a, b) = (NodeId::new(7), NodeId::new(3));
let link = Link::new(a, b).unwrap();
assert_eq!((link.start(), link.end()), (a, b));
assert_eq!(link.key(), LinkKey::new(b, a).unwrap());
let back = link.reversed();
assert_eq!((back.start(), back.end()), (b, a));
assert!(Link::new(a, a).is_none());
}
}
+89
View File
@@ -0,0 +1,89 @@
//! How nodes and lines are drawn: borders, line kinds, arrowheads, colors.
//!
//! Each field of [`NodeLook`] and [`LineLook`] is an override, and `None`
//! means "whatever the theme says". The theme works a look out in layers: its
//! default, then a style set by one of the node's tags, then a named style,
//! then these fields. Each layer only states what it changes.
//!
//! The plain enums here derive serde, so document files can name them
//! directly (`border: Dashed`).
use serde::{Deserialize, Serialize};
use super::text::{ColorName, StyleName};
/// The border around a node's box.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Border {
Rounded,
Square,
Double,
Thick,
Dashed,
/// No border: just the text.
Hidden,
}
/// What a line looks like along its length.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Stroke {
Solid,
Dashed,
Dotted,
Thick,
Double,
}
/// Which ends of a line have an arrowhead. The end is where a cross-link
/// points to; for a branch it's the child.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Arrows {
Off,
End,
Start,
Both,
}
/// The color of a line.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum LineColor {
/// A color from the theme's palette.
Named(ColorName),
/// The color of the node the line comes from (for a branch: the child),
/// so each branch takes its node's color, mind-map style.
FollowNode,
}
/// How a node is drawn, on top of what the theme says.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct NodeLook {
pub color: Option<ColorName>,
pub border: Option<Border>,
/// A named style from the theme, which can set the color and border.
pub style: Option<StyleName>,
}
impl NodeLook {
/// True when nothing is overridden: the node looks the way the theme says.
pub fn is_default(&self) -> bool {
*self == NodeLook::default()
}
}
/// How a line is drawn (a branch or a cross-link), on top of what the theme
/// says.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct LineLook {
pub arrows: Option<Arrows>,
pub line: Option<Stroke>,
pub color: Option<LineColor>,
/// A named style from the theme, which can set all of the above.
pub style: Option<StyleName>,
}
impl LineLook {
/// True when nothing is overridden: the line looks the way the theme says.
pub fn is_default(&self) -> bool {
*self == LineLook::default()
}
}
+85
View File
@@ -0,0 +1,85 @@
//! The data: documents, nodes, links and how they look. No UI and no files
//! here, just the rules.
//!
//! - A [`Document`] owns a tree of [`Node`]s under one root, loose nodes
//! beside it, and cross-[`Link`]s, and keeps its rules (one root, no
//! cycles, links between nodes that exist).
//! - Every change goes through [`Document::edit`], which returns a
//! [`Change`] that [`History`] can undo and redo.
//! - Text from outside ([`Title`], [`Tag`]…) is checked once, when it's
//! turned into these types.
//! - [`Visible`] works out which nodes folding leaves on show.
mod document;
mod edit;
mod fold;
mod geometry;
mod history;
mod ids;
mod link;
mod look;
mod node;
mod portal;
mod text;
pub use document::{Document, NodeRecord, Place, StructureError};
pub use edit::{Change, Edit, EditError, Spot};
pub use fold::Visible;
pub use geometry::{CanvasOffset, CanvasPos};
pub use history::{History, StateId};
pub use ids::{DocUid, NodeId};
pub use link::{Link, LinkKey};
pub use look::{Arrows, Border, LineColor, LineLook, NodeLook, Stroke};
pub use node::{Content, Fold, Idea, Node};
pub use portal::{Backlink, EmptyPath, LinkPath, Portal};
pub use text::{Body, ColorName, Label, StyleName, Tag, Tags, TextError, Title};
/// Small documents and helpers shared by the tests.
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
pub fn title(text: &str) -> Title {
Title::parse(text).expect("a valid title")
}
pub fn idea(text: &str) -> Node {
Node::idea(title(text), CanvasPos::default())
}
/// The ids in [`garden`].
pub struct Garden {
pub root: NodeId,
pub soil: NodeId,
pub beds: NodeId,
pub cedar: NodeId,
pub watering: NodeId,
pub compost: NodeId,
}
/// The document from the design mockup:
///
/// ```text
/// Garden plans ─┬─ Soil
/// ├─ Raised beds ── Cedar
/// └─ Watering
/// ask about compost (loose)
/// ```
pub fn garden() -> (Document, Garden) {
let mut doc = Document::with_uid(title("Garden plans"), DocUid::parse("7f3c9a1e52b04d18").unwrap());
let root = doc.root();
let at = |text: &str, x: i32, y: i32| Node::idea(title(text), CanvasPos::new(x, y));
let (ids, _) = doc
.edit(|e| {
e.update(root, |node| node.pos = CanvasPos::new(0, 4))?;
let soil = e.add(at("Soil", 22, 0), Spot::LastChildOf(root))?;
let beds = e.add(at("Raised beds", 22, 4), Spot::LastChildOf(root))?;
let cedar = e.add(at("Cedar", 40, 4), Spot::LastChildOf(beds))?;
let watering = e.add(at("Watering", 22, 8), Spot::LastChildOf(root))?;
let compost = e.add(at("ask about compost", 0, 14), Spot::Loose)?;
Ok(Garden { root, soil, beds, cedar, watering, compost })
})
.expect("the garden builds");
(doc, ids)
}
}
+80
View File
@@ -0,0 +1,80 @@
//! A node: one idea on the map, or a portal to another document.
use serde::{Deserialize, Serialize};
use super::geometry::CanvasPos;
use super::look::NodeLook;
use super::portal::Portal;
use super::text::{Body, Tags, Title};
/// How a node shows its children.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Fold {
/// The children are always shown.
#[default]
Open,
/// The children are shown while the cursor is on this node or anywhere
/// inside its branch, and fold away again when it leaves.
Auto,
/// The children are hidden. A search or jump that lands inside shows them
/// until the cursor leaves the branch.
Closed,
}
/// What a node is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Content {
/// An idea, with its own title and body.
Idea(Idea),
/// A door to another document: it shows that document's root.
Portal(Portal),
}
/// The text of an idea.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Idea {
pub title: Title,
pub body: Body,
}
/// One node: what it is, its tags, its look, where it sits on the canvas, and
/// how it shows its children. Its place in the tree is kept by the
/// [`Document`](super::Document).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
pub content: Content,
pub tags: Tags,
pub look: NodeLook,
/// The top-left corner of its box on the canvas.
pub pos: CanvasPos,
pub fold: Fold,
}
impl Node {
/// A new idea with just a title, at `pos`.
pub fn idea(title: Title, pos: CanvasPos) -> Node {
Node::with_content(Content::Idea(Idea { title, body: Body::default() }), pos)
}
/// A new portal to another document, at `pos`.
pub fn portal(portal: Portal, pos: CanvasPos) -> Node {
Node::with_content(Content::Portal(portal), pos)
}
fn with_content(content: Content, pos: CanvasPos) -> Node {
Node { content, tags: Tags::default(), look: NodeLook::default(), pos, fold: Fold::default() }
}
/// The title to show: the idea's own, or the one a portal remembers.
pub fn title(&self) -> &Title {
match &self.content {
Content::Idea(idea) => &idea.title,
Content::Portal(portal) => &portal.title,
}
}
/// Whether this node is a portal to another document.
pub fn is_portal(&self) -> bool {
matches!(self.content, Content::Portal(_))
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Links between documents: portals (a node that leads to another document's
//! root) and backlinks (a document remembering the portals that lead to it).
use thiserror::Error;
use super::ids::{DocUid, NodeId};
use super::text::Title;
/// Where another document is, as written in a document file: relative to the
/// folder of the document holding it when possible (`../kitchen.cum`), with
/// `/` between folders on every OS, so folders of documents can move or sync
/// between Windows and Linux.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LinkPath(String);
/// Why a path was refused.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("a path to a document can't be empty")]
pub struct EmptyPath;
impl LinkPath {
/// Reads a path from a file or the command line. `\` becomes `/`.
pub fn parse(text: &str) -> Result<LinkPath, EmptyPath> {
let path = text.trim().replace('\\', "/");
if path.is_empty() { Err(EmptyPath) } else { Ok(LinkPath(path)) }
}
/// The path as written in files.
pub fn as_str(&self) -> &str {
&self.0
}
}
/// A portal: the node it sits on leads to the root of another document.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Portal {
/// Where the other document is.
pub path: LinkPath,
/// The other document's uid, to notice when the file at `path` turns out
/// to be a different document.
pub uid: DocUid,
/// The other document's title when cumulus last saw it, shown if the file
/// goes missing.
pub title: Title,
}
/// A backlink: a portal in another document that leads here.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Backlink {
/// The document holding the portal.
pub from: LinkPath,
/// That document's uid.
pub uid: DocUid,
/// The portal node in that document.
pub node: NodeId,
}
+298
View File
@@ -0,0 +1,298 @@
//! Text that comes from outside (typing, files, the command line), checked
//! once when it's turned into these types. After that, a [`Title`] is always
//! one line that isn't empty, a [`Tag`] is always lowercase without spaces,
//! and so on, so the rest of the code never has to check again.
use std::collections::BTreeSet;
use std::fmt;
use thiserror::Error;
/// Why a piece of text was refused.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TextError {
/// Nothing was left once the text was cleaned up.
#[error("{0} can't be empty")]
Empty(&'static str),
/// A name (of a color or a style) with a character names can't have.
#[error("{what} can't contain {found:?}: use letters, digits, - and _")]
BadNameChar { what: &'static str, found: char },
/// A tag with a character tags can't have.
#[error("tags can't contain {0:?}")]
BadTagChar(char),
}
/// A node's title: one line of text, never empty.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Title(String);
impl Title {
/// Checks a title. Line breaks become spaces (a pasted paragraph turns
/// into one line), tabs become spaces, other control characters are
/// dropped, and the ends are trimmed.
pub fn parse(text: &str) -> Result<Title, TextError> {
not_empty(one_line(text), "a title").map(Title)
}
/// The title's text.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Title {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// A node's body: free text over any number of lines, possibly empty.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct Body(String);
impl Body {
/// Cleans up text for a body: Windows line endings become `\n`, tabs
/// become four spaces (terminals don't show tabs reliably), other control
/// characters are dropped, and blank space at the very end is removed.
pub fn new(text: &str) -> Body {
let mut body = String::with_capacity(text.len());
for (index, line) in text.lines().enumerate() {
if index > 0 {
body.push('\n');
}
for c in line.chars() {
match c {
'\t' => body.push_str(" "),
c if c.is_control() => {}
c => body.push(c),
}
}
}
body.truncate(body.trim_end().len());
Body(body)
}
/// The body's text, with `\n` between lines.
pub fn as_str(&self) -> &str {
&self.0
}
/// Whether there's no body at all.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// A connection's label: one short line of text, never empty.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Label(String);
impl Label {
/// Checks a label, the same way as a [`Title`].
pub fn parse(text: &str) -> Result<Label, TextError> {
not_empty(one_line(text), "a label").map(Label)
}
/// The label's text.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Label {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// A tag, like `#todo`: lowercase, without spaces, kept without its `#`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Tag(String);
impl Tag {
/// Checks a tag. The `#` is optional and capitals become lowercase, so
/// `#Todo` and `todo` are the same tag.
pub fn parse(text: &str) -> Result<Tag, TextError> {
let name = not_empty(text.trim().trim_start_matches('#').to_lowercase(), "a tag")?;
match name.chars().find(|c| c.is_whitespace() || c.is_control() || matches!(c, '#' | ',')) {
Some(found) => Err(TextError::BadTagChar(found)),
None => Ok(Tag(name)),
}
}
/// The tag's name, without the `#`.
pub fn name(&self) -> &str {
&self.0
}
}
impl fmt::Display for Tag {
/// The tag with its `#`: `#todo`.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "#{}", self.0)
}
}
/// A node's tags: sorted, without duplicates.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct Tags(BTreeSet<Tag>);
impl Tags {
/// Reads tags separated by spaces or commas: `#todo #idea`, or
/// `todo, idea`.
pub fn parse(text: &str) -> Result<Tags, TextError> {
text.split(|c: char| c.is_whitespace() || c == ',')
.filter(|word| !word.is_empty())
.map(Tag::parse)
.collect::<Result<BTreeSet<Tag>, TextError>>()
.map(Tags)
}
/// Whether there are no tags.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Whether `tag` is one of them.
pub fn contains(&self, tag: &Tag) -> bool {
self.0.contains(tag)
}
/// Adds a tag. Returns false if it was already there.
pub fn insert(&mut self, tag: Tag) -> bool {
self.0.insert(tag)
}
/// Removes a tag. Returns false if it wasn't there.
pub fn remove(&mut self, tag: &Tag) -> bool {
self.0.remove(tag)
}
/// The tags, in alphabetical order.
pub fn iter(&self) -> impl Iterator<Item = &Tag> {
self.0.iter()
}
}
impl FromIterator<Tag> for Tags {
fn from_iter<I: IntoIterator<Item = Tag>>(tags: I) -> Tags {
Tags(tags.into_iter().collect())
}
}
impl fmt::Display for Tags {
/// The tags with their `#`, separated by spaces: `#idea #todo`.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, tag) in self.0.iter().enumerate() {
if index > 0 {
f.write_str(" ")?;
}
write!(f, "{tag}")?;
}
Ok(())
}
}
/// The name of a color in the theme's palette, like `orange`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ColorName(String);
impl ColorName {
/// Checks a color name: letters, digits, `-` and `_`. Capitals become
/// lowercase.
pub fn parse(text: &str) -> Result<ColorName, TextError> {
parse_name(text, "a color name").map(ColorName)
}
/// The name.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ColorName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// The name of a style in the theme, like `question`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct StyleName(String);
impl StyleName {
/// Checks a style name, the same way as a [`ColorName`].
pub fn parse(text: &str) -> Result<StyleName, TextError> {
parse_name(text, "a style name").map(StyleName)
}
/// The name.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for StyleName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// Turns text into one trimmed line (see [`Title::parse`]).
fn one_line(text: &str) -> String {
let joined = text.lines().map(str::trim).filter(|line| !line.is_empty()).collect::<Vec<_>>().join(" ");
joined.chars().map(|c| if c == '\t' { ' ' } else { c }).filter(|c| !c.is_control()).collect()
}
fn not_empty(text: String, what: &'static str) -> Result<String, TextError> {
if text.is_empty() { Err(TextError::Empty(what)) } else { Ok(text) }
}
fn parse_name(text: &str, what: &'static str) -> Result<String, TextError> {
let name = not_empty(text.trim().to_lowercase(), what)?;
match name.chars().find(|c| !(c.is_alphanumeric() || matches!(c, '-' | '_'))) {
Some(found) => Err(TextError::BadNameChar { what, found }),
None => Ok(name),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn titles_become_one_trimmed_line() {
assert_eq!(Title::parse(" Raised beds ").unwrap().as_str(), "Raised beds");
assert_eq!(
Title::parse("first line\r\nsecond\n\n third").unwrap().as_str(),
"first line second third"
);
assert_eq!(Title::parse("tab\there\u{7}").unwrap().as_str(), "tab here");
assert_eq!(Title::parse(" \n\t "), Err(TextError::Empty("a title")));
}
#[test]
fn bodies_keep_their_lines() {
assert_eq!(Body::new("one\r\ntwo\n\n three\t!\n\n\n").as_str(), "one\ntwo\n\n three !");
assert!(Body::new(" \n \n").is_empty());
}
#[test]
fn tags_are_lowercase_and_sorted() {
let tags = Tags::parse("#Todo idea, #idea later").unwrap();
assert_eq!(tags.to_string(), "#idea #later #todo");
assert_eq!(Tags::parse("").unwrap(), Tags::default());
assert_eq!(Tag::parse("##"), Err(TextError::Empty("a tag")));
assert_eq!(Tag::parse("a#b"), Err(TextError::BadTagChar('#')));
}
#[test]
fn names_allow_letters_digits_dashes() {
assert_eq!(ColorName::parse(" Sky-Blue_2 ").unwrap().as_str(), "sky-blue_2");
assert_eq!(
StyleName::parse("big idea"),
Err(TextError::BadNameChar { what: "a style name", found: ' ' })
);
assert_eq!(ColorName::parse(""), Err(TextError::Empty("a color name")));
}
}
+528
View File
@@ -0,0 +1,528 @@
//! Document files (`.cum`): RON text to a [`Document`] and back.
//!
//! The `File…` types below mirror the model with plain strings and numbers,
//! so serde can read anything shaped right. Turning them into the model then
//! checks every rule, and an error names the node it's about, so a typo made
//! while editing by hand is easy to find.
use ron::Options;
use ron::extensions::Extensions;
use ron::ser::PrettyConfig;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::model::{
Arrows, Backlink, Body, Border, CanvasPos, ColorName, Content, DocUid, Document, Fold, Idea, Label,
LineColor, LineLook, Link, LinkPath, Node, NodeId, NodeLook, NodeRecord, Place, Portal, Stroke,
StructureError, StyleName, Tags, Title,
};
/// The format written by this version of cumulus.
const FORMAT: u32 = 1;
const HEADER: &str = "// cumulus document. Hand edits are fine: cumulus reloads it when it changes.\n";
/// In a line's `color`, this word means "the node's own color".
const FOLLOW_NODE: &str = "node";
/// Why a document file couldn't be read.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum LoadError {
/// The text isn't RON shaped like a document (with where it went wrong).
#[error("line {line}, column {column}: {message}")]
Syntax { line: usize, column: usize, message: String },
#[error("this file was written by a newer cumulus (format {0}): update cumulus to open it")]
NewerFormat(u32),
#[error("format {0} isn't a cumulus document format")]
UnknownFormat(u32),
#[error("the uid {0:?} should be 16 hexadecimal digits")]
BadUid(String),
/// Something about one node (a link's or backlink's problem names its
/// node too).
#[error("node {id}: {problem}")]
Node { id: u32, problem: String },
#[error(transparent)]
Structure(#[from] StructureError),
}
/// Reads a document from the text of a `.cum` file.
pub fn parse(text: &str) -> Result<Document, LoadError> {
let file: FileDocument = options().from_str(text).map_err(|error| LoadError::Syntax {
line: error.span.start.line,
column: error.span.start.col,
message: error.code.to_string(),
})?;
if file.format > FORMAT {
return Err(LoadError::NewerFormat(file.format));
}
if file.format == 0 {
return Err(LoadError::UnknownFormat(file.format));
}
let uid = DocUid::parse(&file.uid).ok_or_else(|| LoadError::BadUid(file.uid.clone()))?;
let records = file.nodes.into_iter().map(FileNode::into_record).collect::<Result<Vec<_>, _>>()?;
let links = file.links.into_iter().map(FileLink::into_link).collect::<Result<Vec<_>, _>>()?;
let backlinks =
file.backlinks.into_iter().map(FileBacklink::into_backlink).collect::<Result<Vec<_>, _>>()?;
Ok(Document::from_records(uid, NodeId::new(file.root), records, links, backlinks)?)
}
/// Writes a document as the text of a `.cum` file: the nodes in reading
/// order, then the links and the backlinks.
pub fn write(doc: &Document) -> Result<String, ron::Error> {
let file = FileDocument {
format: FORMAT,
uid: doc.uid().to_string(),
root: doc.root().get(),
nodes: doc.outline().into_iter().filter_map(|id| FileNode::from_doc(doc, id)).collect(),
links: doc.links().map(FileLink::from_link).collect(),
backlinks: doc.backlinks().iter().map(FileBacklink::from_backlink).collect(),
};
// Long text is written over several lines instead of with `\n`, so bodies
// stay readable in the file. Lines end with `\n` on every OS (ron would
// use `\r\n` on Windows), so a file is the same wherever it was saved.
let pretty = PrettyConfig::new().struct_names(false).escape_strings(false).new_line("\n");
let ron = options().to_string_pretty(&file, pretty)?;
Ok(format!("{HEADER}{ron}\n"))
}
/// RON as cumulus reads and writes it: `parent: 1` instead of RON's usual
/// `parent: Some(1)`.
fn options() -> Options {
Options::default().with_default_extension(Extensions::IMPLICIT_SOME)
}
// ----- What the file holds --------------------------------------------------
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileDocument {
format: u32,
uid: String,
root: u32,
nodes: Vec<FileNode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
links: Vec<FileLink>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
backlinks: Vec<FileBacklink>,
}
/// A node. It has a `title` (an idea) or a `portal`, never both.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileNode {
id: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
portal: Option<FilePortal>,
pos: (i32, i32),
#[serde(default, skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
border: Option<Border>,
#[serde(default, skip_serializing_if = "Option::is_none")]
style: Option<String>,
/// All the tags in one string: `"#idea #todo"`.
#[serde(default, skip_serializing_if = "String::is_empty")]
tags: String,
#[serde(default, skip_serializing_if = "is_open")]
fold: Fold,
/// How the line from the parent is drawn.
#[serde(default, skip_serializing_if = "FileLineLook::is_default")]
branch: FileLineLook,
#[serde(default, skip_serializing_if = "String::is_empty")]
body: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FilePortal {
path: String,
uid: String,
title: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileLineLook {
#[serde(default, skip_serializing_if = "Option::is_none")]
line: Option<Stroke>,
#[serde(default, skip_serializing_if = "Option::is_none")]
arrows: Option<Arrows>,
#[serde(default, skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
style: Option<String>,
}
/// A cross-link. Its look sits directly in it, so a link reads as one short
/// list: `(from: 2, to: 4, label: "needs", line: Dashed)`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileLink {
from: u32,
to: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
line: Option<Stroke>,
#[serde(default, skip_serializing_if = "Option::is_none")]
arrows: Option<Arrows>,
#[serde(default, skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
style: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileBacklink {
from: String,
uid: String,
node: u32,
}
fn is_open(fold: &Fold) -> bool {
*fold == Fold::Open
}
// ----- File to model ----------------------------------------------------------
impl FileNode {
fn into_record(self) -> Result<NodeRecord, LoadError> {
let id = self.id;
let problem = |problem: String| LoadError::Node { id, problem };
let content = match (self.title, self.portal) {
(Some(title), None) => Content::Idea(Idea {
title: Title::parse(&title).map_err(|error| problem(error.to_string()))?,
body: Body::new(&self.body),
}),
(None, Some(portal)) if self.body.is_empty() => {
Content::Portal(portal.into_portal().map_err(problem)?)
}
(None, Some(_)) => return Err(problem("a portal has no body".into())),
(Some(_), Some(_)) => return Err(problem("has both a title and a portal".into())),
(None, None) => return Err(problem("needs a title (or a portal)".into())),
};
let node = Node {
content,
tags: Tags::parse(&self.tags).map_err(|error| problem(error.to_string()))?,
look: NodeLook {
color: self
.color
.as_deref()
.map(ColorName::parse)
.transpose()
.map_err(|e| problem(e.to_string()))?,
border: self.border,
style: self
.style
.as_deref()
.map(StyleName::parse)
.transpose()
.map_err(|e| problem(e.to_string()))?,
},
pos: CanvasPos::new(self.pos.0, self.pos.1),
fold: self.fold,
};
Ok(NodeRecord {
id: NodeId::new(id),
parent: self.parent.map(NodeId::new),
branch: self.branch.into_look().map_err(problem)?,
node,
})
}
}
impl FilePortal {
fn into_portal(self) -> Result<Portal, String> {
Ok(Portal {
path: LinkPath::parse(&self.path).map_err(|error| error.to_string())?,
uid: DocUid::parse(&self.uid)
.ok_or(format!("the portal's uid {:?} isn't 16 hex digits", self.uid))?,
title: Title::parse(&self.title).map_err(|error| error.to_string())?,
})
}
}
impl FileLineLook {
fn is_default(&self) -> bool {
self.arrows.is_none() && self.line.is_none() && self.color.is_none() && self.style.is_none()
}
fn into_look(self) -> Result<LineLook, String> {
line_look(self.arrows, self.line, self.color, self.style)
}
}
/// A line's look from its four file fields.
fn line_look(
arrows: Option<Arrows>,
line: Option<Stroke>,
color: Option<String>,
style: Option<String>,
) -> Result<LineLook, String> {
let color = match color.as_deref() {
None => None,
Some(FOLLOW_NODE) => Some(LineColor::FollowNode),
Some(name) => Some(LineColor::Named(ColorName::parse(name).map_err(|error| error.to_string())?)),
};
let style = style.as_deref().map(StyleName::parse).transpose().map_err(|error| error.to_string())?;
Ok(LineLook { arrows, line, color, style })
}
impl FileLink {
fn into_link(self) -> Result<Link, LoadError> {
let problem = |problem: String| LoadError::Node { id: self.from, problem };
let mut link = Link::new(NodeId::new(self.from), NodeId::new(self.to))
.ok_or_else(|| problem("a link can't go from a node to itself".into()))?;
link.label = self
.label
.as_deref()
.map(Label::parse)
.transpose()
.map_err(|error| problem(format!("the link to {}: {error}", self.to)))?;
link.look = line_look(self.arrows, self.line, self.color.clone(), self.style.clone())
.map_err(|error| problem(format!("the link to {}: {error}", self.to)))?;
Ok(link)
}
}
impl FileBacklink {
fn into_backlink(self) -> Result<Backlink, LoadError> {
let problem = |problem: String| LoadError::Node { id: self.node, problem };
Ok(Backlink {
from: LinkPath::parse(&self.from).map_err(|error| problem(format!("backlink: {error}")))?,
uid: DocUid::parse(&self.uid)
.ok_or_else(|| problem(format!("backlink: the uid {:?} isn't 16 hex digits", self.uid)))?,
node: NodeId::new(self.node),
})
}
}
// ----- Model to file ----------------------------------------------------------
impl FileNode {
fn from_doc(doc: &Document, id: NodeId) -> Option<FileNode> {
let node = doc.node(id)?;
let (parent, branch) = match doc.place(id)? {
Place::Child { parent, branch } => (Some(parent.get()), FileLineLook::from_look(branch)),
Place::Root | Place::Loose => (None, FileLineLook::default()),
};
let (title, portal, body) = match &node.content {
Content::Idea(idea) => (Some(idea.title.to_string()), None, idea.body.as_str().to_string()),
Content::Portal(portal) => (None, Some(FilePortal::from_portal(portal)), String::new()),
};
Some(FileNode {
id: id.get(),
parent,
title,
portal,
pos: (node.pos.x, node.pos.y),
color: node.look.color.as_ref().map(ToString::to_string),
border: node.look.border,
style: node.look.style.as_ref().map(ToString::to_string),
tags: node.tags.to_string(),
fold: node.fold,
branch,
body,
})
}
}
impl FilePortal {
fn from_portal(portal: &Portal) -> FilePortal {
FilePortal {
path: portal.path.as_str().to_string(),
uid: portal.uid.to_string(),
title: portal.title.to_string(),
}
}
}
impl FileLineLook {
fn from_look(look: &LineLook) -> FileLineLook {
FileLineLook {
arrows: look.arrows,
line: look.line,
color: look.color.as_ref().map(line_color_name),
style: look.style.as_ref().map(ToString::to_string),
}
}
}
fn line_color_name(color: &LineColor) -> String {
match color {
LineColor::FollowNode => FOLLOW_NODE.to_string(),
LineColor::Named(name) => name.to_string(),
}
}
impl FileLink {
fn from_link(link: &Link) -> FileLink {
FileLink {
from: link.start().get(),
to: link.end().get(),
label: link.label.as_ref().map(ToString::to_string),
arrows: link.look.arrows,
line: link.look.line,
color: link.look.color.as_ref().map(line_color_name),
style: link.look.style.as_ref().map(ToString::to_string),
}
}
}
impl FileBacklink {
fn from_backlink(backlink: &Backlink) -> FileBacklink {
FileBacklink {
from: backlink.from.as_str().to_string(),
uid: backlink.uid.to_string(),
node: backlink.node.get(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::test_support::{garden, title};
use crate::model::{Spot, Tag};
/// The example from DESIGN.md, as someone might type it.
const DESIGN_SAMPLE: &str = r##"// cumulus document. Hand edits are fine: cumulus reloads it when it changes.
(
format: 1,
uid: "7f3c9a1e52b04d18",
root: 1,
nodes: [
(
id: 1,
title: "Garden plans",
pos: (0, 4),
color: "green",
),
(
id: 2,
parent: 1,
title: "Raised beds",
pos: (22, 4),
tags: "#todo",
fold: Auto,
body: "Cedar, three of them.
40 cm high.",
),
(
id: 3,
parent: 1,
portal: (
path: "kitchen.cum",
uid: "c01d5e7aa0f3b912",
title: "Kitchen",
),
pos: (22, 8),
),
(
id: 4,
title: "ask about compost",
pos: (0, 14),
),
],
links: [
(
from: 2,
to: 4,
label: "needs",
line: Dashed,
arrows: End,
),
],
)
"##;
#[test]
fn the_design_sample_reads_and_writes_back_the_same() {
let doc = parse(DESIGN_SAMPLE).unwrap();
doc.assert_rules();
let beds = doc.node(NodeId::new(2)).unwrap();
assert_eq!(beds.fold, Fold::Auto);
assert!(beds.tags.contains(&Tag::parse("todo").unwrap()));
let Content::Idea(idea) = &beds.content else { panic!("an idea") };
assert_eq!(idea.body.as_str(), "Cedar, three of them.\n40 cm high.");
assert!(doc.node(NodeId::new(3)).unwrap().is_portal());
assert_eq!(doc.loose(), &[NodeId::new(4)]);
assert_eq!(write(&doc).unwrap(), DESIGN_SAMPLE, "cumulus writes exactly this format");
}
#[test]
fn everything_survives_a_round_trip() {
let (mut doc, g) = garden();
doc.edit(|e| {
e.update(g.beds, |node| {
node.look = NodeLook {
color: Some(ColorName::parse("orange").unwrap()),
border: Some(Border::Double),
style: Some(StyleName::parse("question").unwrap()),
};
node.tags = Tags::parse("#todo #later").unwrap();
node.fold = Fold::Closed;
if let Content::Idea(idea) = &mut node.content {
idea.body = Body::new("He said \"cedar\".\nC:\\garden\\plan.txt");
}
})?;
e.set_branch_look(
g.cedar,
LineLook {
line: Some(Stroke::Dotted),
color: Some(LineColor::FollowNode),
..LineLook::default()
},
)?;
let mut link = Link::new(g.compost, g.soil).unwrap();
link.label = Some(Label::parse("feeds").unwrap());
link.look.arrows = Some(Arrows::Both);
e.link(link)?;
let portal = Portal {
path: LinkPath::parse("../kitchen.cum").unwrap(),
uid: DocUid::parse("c01d5e7aa0f3b912").unwrap(),
title: title("Kitchen"),
};
e.add(Node::portal(portal, CanvasPos::new(-30, -2)), Spot::LastChildOf(g.root))
})
.unwrap();
doc.set_backlinks(vec![Backlink {
from: LinkPath::parse("../projects.cum").unwrap(),
uid: DocUid::parse("aa12f0c3d9e8b7a6").unwrap(),
node: NodeId::new(9),
}]);
let text = write(&doc).unwrap();
assert_eq!(parse(&text).unwrap(), doc, "{text}");
}
#[test]
fn mistakes_name_the_node_or_the_place() {
let node_error = |text: &str| parse(text).unwrap_err().to_string();
let doc = |nodes: &str| format!(r#"(format: 1, uid: "7f3c9a1e52b04d18", root: 1, nodes: [{nodes}])"#);
assert_eq!(node_error(&doc(r#"(id: 1, title: " ", pos: (0, 0))"#)), "node 1: a title can't be empty");
assert_eq!(node_error(&doc(r#"(id: 1, pos: (0, 0))"#)), "node 1: needs a title (or a portal)");
assert_eq!(
node_error(&doc(
r#"(id: 1, title: "a", pos: (0, 0)), (id: 2, parent: 7, title: "b", pos: (0, 0))"#
)),
"node 2 has parent 7, which isn't in the file"
);
assert_eq!(
node_error(&doc(r##"(id: 1, title: "a", pos: (0, 0), tags: "#a,#b c#d")"##)),
"node 1: tags can't contain '#'"
);
let typo = node_error(&doc(r#"(id: 1, title: "a", pos: (0, 0), colour: "red")"#));
assert!(typo.starts_with("line 1, column ") && typo.contains("colour"), "{typo}");
assert_eq!(
node_error(r#"(format: 9, uid: "7f3c9a1e52b04d18", root: 1, nodes: [])"#),
"this file was written by a newer cumulus (format 9): update cumulus to open it"
);
}
}
+4
View File
@@ -0,0 +1,4 @@
//! Files. For now: reading and writing documents. Saving safely, backups,
//! locks and the data folder come with the app core (DESIGN.md, stage 8).
pub mod document_file;