Files
cumulus/src/app/buffer.rs
T
skkeyeandClaude Opus 5.5 6eedbe0ce2 Pull the view interface, actions and modes out of the canvas example
The canvas example did everything itself; now it runs the way the app
will. The key engine turns keys into commands, the focused view answers
with actions, and the commands every view shares (undo, marks, adding,
deleting, folds, tree moves, modes) answer the rest. A view can still
give a key its own meaning, since it gets the first say. `:` lines become
actions the same way (app::calls).

- View trait: kind, contexts (its key tables), layout, render, command
  and mouse. layout comes before render because a view keeps the frame
  it drew, to match clicks against, and follows the cursor with its
  camera; drawing changes nothing. CanvasView owns the camera, the last
  scene and the mouse drag; it reads the rest from a ViewContext.
- Edits travel as data (EditOp), so views never touch the document.
  Buffer::apply is the one place a document changes: the edit, its undo
  step, where the cursor goes, marks and `''`.
- Mode holds each mode's own state: move mode carries its Grab (whole
  branch or not), the command line lives in Mode::Command.
- Undo groups: a whole move-mode session or mouse drag is one undo step,
  built up as it goes. After an undo the cursor lands on a node the
  change added or removed, before the others it touched.
- Actions run in order, and a problem stops the rest.

Small improvements on the way: zz centers the middle of the node, :find
says which match it's on, :unlink with no links says so, folding a node
without children says why nothing happened, and a captured idea is
placed next to a new Inbox even on the first :in.

The example has end-to-end tests now: they type key sequences and mouse
gestures into it on a test terminal, and `cargo test` runs them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 04:46:05 -04:00

191 lines
7.1 KiB
Rust

//! An open document: the document, its undo history, its cursor and its
//! marks. [`Buffer::apply`] is the one place where a document changes.
use std::collections::BTreeMap;
use super::action::{CursorAfter, DocAction, Message};
use crate::model::{Applied, Document, History, NodeId};
/// An open document, with what goes with it while it's open.
#[derive(Debug)]
pub struct Buffer {
doc: Document,
history: History,
cursor: NodeId,
marks: BTreeMap<char, NodeId>,
/// Where the cursor was before the last jump, for `''`.
before_jump: Option<NodeId>,
}
impl Buffer {
/// A buffer for a document, with the cursor on its root.
pub fn new(doc: Document) -> Buffer {
Buffer {
cursor: doc.root(),
doc,
history: History::default(),
marks: BTreeMap::new(),
before_jump: None,
}
}
pub fn doc(&self) -> &Document {
&self.doc
}
/// The node the cursor is on (always one that exists).
pub fn cursor(&self) -> NodeId {
self.cursor
}
pub fn history(&self) -> &History {
&self.history
}
/// Carries out an action on the document or its cursor, and says how it
/// went when there's something to say.
pub fn apply(&mut self, action: DocAction) -> Option<Message> {
match action {
DocAction::MoveCursor { to, jump } => self.move_cursor(to, jump),
DocAction::Edit { op, cursor, group } => {
let (applied, change) = match self.doc.apply(&op) {
Ok(done) => done,
Err(error) => return Some(Message::problem(error.to_string())),
};
match group {
Some(group) => self.history.record_in(change, group),
None => self.history.record(change),
}
self.cursor = match (cursor, applied) {
(CursorAfter::Added, Applied::Added(id)) | (CursorAfter::To(id), _) => id,
_ => self.cursor,
};
self.keep_cursor();
match applied {
Applied::Removed(count) => {
Some(Message::done(format!("deleted {} · u to undo", nodes(count))))
}
Applied::Added(_) | Applied::Changed => None,
}
}
DocAction::Undo(times) => self.undo_or_redo(times, true),
DocAction::Redo(times) => self.undo_or_redo(times, false),
DocAction::SetMark(name) => {
self.marks.insert(name, self.cursor);
let title =
self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default();
Some(Message::done(format!("mark {name} is on \"{title}\": '{name} comes back here")))
}
DocAction::JumpToMark('\'' | '`') => match self.before_jump {
Some(id) if self.doc.contains(id) => self.move_cursor(id, true),
_ => Some(Message::info("no jump to go back from yet")),
},
DocAction::JumpToMark(name) => match self.marks.get(&name) {
Some(&id) if self.doc.contains(id) => self.move_cursor(id, true),
Some(_) => Some(Message::problem(format!("the node marked {name} is gone"))),
None => Some(Message::info(format!("no mark {name} yet: m{name} sets it"))),
},
}
}
fn move_cursor(&mut self, to: NodeId, jump: bool) -> Option<Message> {
if !self.doc.contains(to) {
return Some(Message::problem("that node is gone"));
}
if jump && to != self.cursor {
self.before_jump = Some(self.cursor);
}
self.cursor = to;
None
}
/// Undoes (or redoes) up to `times` changes; the cursor goes where the
/// last one happened.
fn undo_or_redo(&mut self, times: u32, undo: bool) -> Option<Message> {
let mut landed = None;
for _ in 0..times {
let change =
if undo { self.history.undo(&mut self.doc) } else { self.history.redo(&mut self.doc) };
let Some(change) = change else { break };
landed = Some(change.nodes().find(|id| self.doc.contains(*id)));
}
match landed {
None => Some(Message::info(if undo { "nothing to undo" } else { "nothing to redo" })),
Some(target) => {
self.cursor = target.unwrap_or(self.cursor);
self.keep_cursor();
None
}
}
}
/// The cursor's node may be gone after an edit or an undo: then it goes
/// to the root.
fn keep_cursor(&mut self) {
if !self.doc.contains(self.cursor) {
self.cursor = self.doc.root();
}
}
}
/// "1 node", "3 nodes".
fn nodes(count: usize) -> String {
if count == 1 { "1 node".to_string() } else { format!("{count} nodes") }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::test_support::{garden, idea};
use crate::model::{EditOp, Spot};
fn edit(op: EditOp, cursor: CursorAfter) -> DocAction {
DocAction::Edit { op: Box::new(op), cursor, group: None }
}
#[test]
fn edits_move_the_cursor_and_undo_brings_it_back() {
let (doc, g) = garden();
let mut buffer = Buffer::new(doc);
buffer.apply(edit(
EditOp::Add { node: idea("new"), spot: Spot::LastChildOf(g.soil) },
CursorAfter::Added,
));
let added = buffer.cursor();
assert_eq!(buffer.doc().parent(added), Some(g.soil));
let message = buffer.apply(edit(EditOp::RemoveBranches(vec![g.soil]), CursorAfter::To(g.beds)));
assert_eq!(message, Some(Message::done("deleted 2 nodes · u to undo")));
assert_eq!(buffer.cursor(), g.beds);
buffer.apply(DocAction::Undo(1));
assert_eq!(buffer.cursor(), g.soil, "back where the change was");
assert_eq!(buffer.apply(DocAction::Undo(5)), None);
assert_eq!(buffer.apply(DocAction::Undo(1)), Some(Message::info("nothing to undo")));
}
#[test]
fn marks_and_jumping_back() {
let (doc, g) = garden();
let mut buffer = Buffer::new(doc);
buffer.apply(DocAction::MoveCursor { to: g.cedar, jump: false });
buffer.apply(DocAction::SetMark('a'));
buffer.apply(DocAction::MoveCursor { to: g.compost, jump: true });
buffer.apply(DocAction::JumpToMark('a'));
assert_eq!(buffer.cursor(), g.cedar);
buffer.apply(DocAction::JumpToMark('\''));
assert_eq!(buffer.cursor(), g.compost, "'' goes back to before the jump");
assert_eq!(
buffer.apply(DocAction::JumpToMark('z')),
Some(Message::info("no mark z yet: mz sets it"))
);
}
#[test]
fn the_cursor_never_rests_on_a_deleted_node() {
let (doc, g) = garden();
let mut buffer = Buffer::new(doc);
buffer.apply(DocAction::MoveCursor { to: g.cedar, jump: false });
buffer.apply(edit(EditOp::RemoveBranches(vec![g.beds]), CursorAfter::Stay));
assert_eq!(buffer.cursor(), buffer.doc().root());
}
}