Files
cumulus/examples/canvas.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

568 lines
23 KiB
Rust

//! A playground for the canvas (stages 2 to 5 of the build): a sample
//! document to move around, zoom, fold and rearrange, with the real vim keys,
//! the which-key popup and the `:` command line. Text editing and the other
//! views come in later stages.
//!
//! It runs the way the app will: the key engine turns keys into commands,
//! the canvas view (or the commands every view shares) turns those into
//! actions, and [`Playground::update`] carries them out.
//!
//! Run it with `cargo run --example canvas`. A few keys to start with:
//!
//! | Keys | What they do |
//! |----------------|------------------------------------------------------|
//! | `hjkl`, arrows | move to the nearest node that way (`5j`: five times) |
//! | `g` | go to: `gh gl gj gk` the tree, `gg` the root |
//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` |
//! | `+` `-` | zoom in / out |
//! | `a` `o` `O` | add a child / a sibling below / above |
//! | `x` `dd` `dib` | delete the node / the branch / the children |
//! | `M` | move mode: hjkl moves the branch, Enter ends |
//! | `u` `Ctrl-r` | undo / redo |
//! | `ma` `'a` | mark a node / jump back to it (`''`: where you were) |
//! | `<Space>` | the action menu (`<Space>w`: key hints on or off) |
//! | `:` | the command line: try `:fold auto`, `:color teal`, `:in an idea`, `:link rain`, `:find soil` (Tab completes) |
//! | `q`, `ZZ` | quit |
//!
//! Mouse: click selects, dragging a node moves its branch, dragging empty
//! space pans, the wheel scrolls.
use std::io;
use std::time::{Duration, Instant};
use cumulus::app::action::{Action, AppAction, Message, Tone};
use cumulus::app::buffer::Buffer;
use cumulus::app::mode::{Grab, Mode};
use cumulus::app::{calls, commands};
use cumulus::command::history::History as CommandHistory;
use cumulus::command::parse::{SetArg, Sources, parse};
use cumulus::input::command::{Bound, Command, help_for};
use cumulus::input::engine::{KeyEngine, Outcome};
use cumulus::input::key::Key;
use cumulus::input::keymap::Keymap;
use cumulus::input::mouse::{Gesture, MouseReader};
use cumulus::model::Document;
use cumulus::storage::document_file;
use cumulus::ui::command_line::{CommandLine, LineOutcome, PopupMode};
use cumulus::ui::theme::Theme;
use cumulus::ui::which_key::WhichKey;
use cumulus::view::canvas::CanvasView;
use cumulus::view::{View, ViewContext, ViewSettings};
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
use ratatui::crossterm::execute;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::{DefaultTerminal, Frame};
/// The playground's own keys.toml: `q` quits. (cumulus itself keeps `q`
/// free for macros; this shows how keys.toml changes a key.)
const KEYS_TOML: &str = r#"
[normal]
"q" = "close"
"#;
/// How long a sequence waits before its which-key popup shows (the leader's
/// shows right away).
const WHICH_KEY_DELAY: Duration = Duration::from_millis(300);
fn main() -> io::Result<()> {
let doc = document_file::parse(include_str!("garden.cum")).map_err(io::Error::other)?;
let mut terminal = ratatui::try_init()?;
execute!(io::stdout(), EnableMouseCapture)?;
// On a crash, turn the mouse off too; ratatui's own hook restores the rest.
let restore_the_rest = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = execute!(io::stdout(), DisableMouseCapture);
restore_the_rest(info);
}));
let result = Playground::new(doc).run(&mut terminal);
let _ = execute!(io::stdout(), DisableMouseCapture);
ratatui::restore();
result
}
/// What the view looks at.
struct State {
buffer: Buffer,
mode: Mode,
theme: Theme,
settings: ViewSettings,
}
impl State {
fn ctx(&self) -> ViewContext<'_> {
ViewContext {
doc: self.buffer.doc(),
cursor: self.buffer.cursor(),
mode: &self.mode,
theme: &self.theme,
settings: &self.settings,
}
}
}
struct Playground {
/// The one view (splits come in stage 7).
view: CanvasView,
state: State,
keymap: Keymap,
keys: KeyEngine,
mouse: MouseReader,
which_key: bool,
command_history: CommandHistory,
/// When the command line's popup shows (`:set completion_popup=on-tab`).
popup: PopupMode,
/// The status bar's message, until the next key.
message: Option<Message>,
quit: bool,
}
impl Playground {
fn new(doc: Document) -> Playground {
let (keymap, problems) = Keymap::from_toml(KEYS_TOML);
Playground {
view: CanvasView::new(),
state: State {
buffer: Buffer::new(doc),
mode: Mode::Normal,
theme: Theme::default(),
settings: ViewSettings::default(),
},
keymap,
keys: KeyEngine::default(),
mouse: MouseReader::default(),
which_key: true,
command_history: CommandHistory::default(),
popup: PopupMode::default(),
message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))),
quit: false,
}
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
while !self.quit {
terminal.draw(|frame| self.draw(frame))?;
// While a sequence is half typed, wake up now and then: its
// which-key popup may be due.
let wait =
if self.keys.is_pending() { Duration::from_millis(50) } else { Duration::from_secs(3600) };
if !event::poll(wait)? {
continue;
}
match event::read()? {
// Windows reports key releases too: only presses and repeats count.
Event::Key(event) if event.kind != KeyEventKind::Release => {
if let Some(key) = Key::from_event(event) {
self.key(key);
}
}
Event::Mouse(event) => {
if let Some(gesture) = self.mouse.read(event, Instant::now()) {
self.gesture(gesture);
}
}
_ => {}
}
}
Ok(())
}
// ----- Input ----------------------------------------------------------
fn gesture(&mut self, gesture: Gesture) {
let actions = self.view.mouse(gesture, &self.state.ctx());
self.update(actions);
}
fn key(&mut self, key: Key) {
self.message = None;
if let Mode::Command(_) = self.state.mode {
return self.command_line_key(key);
}
let tables = self.keymap.tables(self.view.contexts(&self.state.mode));
match self.keys.press(key, &tables) {
Outcome::Run(typed) => {
// The view has the first say; the shared commands get the rest.
let ctx = self.state.ctx();
let actions = self.view.command(&typed, &ctx).or_else(|| commands::shared(&typed, &ctx));
let actions = actions.unwrap_or_else(|| {
let later =
format!("\"{}\" comes in a later stage of the build", describe(typed.command));
vec![Action::say(Message::info(later))]
});
self.update(actions);
}
Outcome::Unknown(keys) => self.message = Some(Message::info(format!("{keys} does nothing here"))),
Outcome::Waiting | Outcome::Cancelled => {}
}
}
/// A key while the command line is open.
fn command_line_key(&mut self, key: Key) {
let sources = self.sources();
let Mode::Command(line) = &mut self.state.mode else { return };
match line.key(key, &sources, &self.command_history) {
LineOutcome::Typing => {}
LineOutcome::Close => self.state.mode = Mode::Normal,
LineOutcome::Run(text) => {
self.state.mode = Mode::Normal;
if text.trim().is_empty() {
return;
}
self.command_history.add(&text);
let actions = match parse(&text, &sources) {
Ok(call) => calls::actions_for(call, &self.state.ctx()),
Err(error) => vec![Action::say(Message::problem(error.to_string()))],
};
self.update(actions);
}
}
}
/// What the command line can name: this document, its nodes, the
/// palette's colors, its tags.
fn sources(&self) -> Sources {
let doc = self.state.buffer.doc();
let nodes = doc.outline().into_iter().filter_map(|id| Some((id, doc.node(id)?.title().to_string())));
let mut tags: Vec<String> = doc
.outline()
.into_iter()
.filter_map(|id| doc.node(id))
.flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::<Vec<_>>())
.collect();
tags.sort();
tags.dedup();
Sources {
documents: vec![doc.title().to_string()],
nodes: nodes.collect(),
colors: self.state.theme.palette.keys().map(ToString::to_string).collect(),
styles: self.state.theme.node_styles.keys().map(ToString::to_string).collect(),
themes: vec!["default".into()],
settings: vec!["completion_popup".into()],
tags,
folder: std::env::current_dir().unwrap_or_default(),
}
}
// ----- Update ---------------------------------------------------------
/// Carries out actions in order. A problem stops the rest, since they
/// may count on it.
fn update(&mut self, actions: Vec<Action>) {
for action in actions {
let message = match action {
Action::Doc(action) => self.state.buffer.apply(action),
Action::App(action) => self.app(action),
};
if let Some(message) = message {
let problem = message.tone == Tone::Problem;
self.message = Some(message);
if problem {
break;
}
}
}
}
fn app(&mut self, action: AppAction) -> Option<Message> {
match action {
AppAction::EnterMoveMode => self.state.mode = Mode::Move(Grab::new()),
AppAction::ToggleWhole => {
if let Mode::Move(grab) = &mut self.state.mode {
grab.whole = !grab.whole;
}
}
AppAction::LeaveMode => self.state.mode = Mode::Normal,
AppAction::OpenCommandLine => {
let line = CommandLine::new(&self.sources(), &self.command_history, self.popup);
self.state.mode = Mode::Command(line);
}
AppAction::ToggleFocus => {
let focus = &mut self.state.settings.focus;
*focus = !*focus;
let text =
if *focus { "focus on: branches the cursor isn't in fold away" } else { "focus off" };
return Some(Message::info(text));
}
AppAction::ToggleWhichKey => {
self.which_key = !self.which_key;
return Some(Message::info(if self.which_key { "key hints on" } else { "key hints off" }));
}
AppAction::Set(arg) => return Some(self.set(arg)),
AppAction::Quit => self.quit = true,
AppAction::Say(message) => return Some(message),
}
None
}
/// `:set`. The playground has one setting; the app's come in stage 8.
fn set(&mut self, arg: SetArg) -> Message {
let value = match arg {
SetArg::Value(_, value) => value,
SetArg::Reset(_) => popup_name(PopupMode::default()).to_string(),
SetArg::Show(_) => {
return Message::info(format!("completion_popup = {}", popup_name(self.popup)));
}
SetArg::On(_) | SetArg::Off(_) | SetArg::Toggle(_) => {
return Message::problem("completion_popup takes a value: :set completion_popup=on-tab");
}
};
match PopupMode::named(&value) {
Some(popup) => {
self.popup = popup;
Message::done(format!("completion_popup = {value}"))
}
None => {
let values: Vec<&str> = PopupMode::VALUES.iter().map(|(name, _)| *name).collect();
Message::problem(format!("completion_popup can be {}", values.join(" or ")))
}
}
}
// ----- Drawing --------------------------------------------------------
fn draw(&mut self, frame: &mut Frame) {
let [canvas, status] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
let ctx = self.state.ctx();
self.view.layout(canvas, &ctx);
self.view.render(frame, &ctx);
// While the command line is open, it takes the status line's place.
if let Mode::Command(line) = &self.state.mode {
line.render(frame, status, canvas, &self.state.theme);
return;
}
if self.which_key_due()
&& let Some(hints) = self.keys.hints(&self.keymap.tables(self.view.contexts(&self.state.mode)))
{
frame.render_widget(WhichKey { hints: &hints, theme: &self.state.theme }, canvas);
}
self.draw_status(frame, status);
}
/// The bottom line: the mode, the zoom and the cursor's title, then a
/// message or key hints, and the keys typed so far on the right.
fn draw_status(&self, frame: &mut Frame, area: Rect) {
let hints = match self.state.mode {
Mode::Move(Grab { whole: true, .. }) => {
"hjkl moves the branch (HJKL by 5) · M just this node · Enter done"
}
Mode::Move(_) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done",
Mode::Normal | Mode::Command(_) => {
"hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · : command · <Space> menu · q quit"
}
};
let doc = self.state.buffer.doc();
let title =
doc.node(self.state.buffer.cursor()).map(|node| node.title().to_string()).unwrap_or_default();
let focus = if self.state.settings.focus { " · focus" } else { "" };
let middle = match &self.message {
None => hints.dark_gray(),
Some(Message { text, tone: Tone::Problem }) => text.clone().light_red(),
Some(Message { text, tone: Tone::Done }) => text.clone().green(),
Some(Message { text, tone: Tone::Info }) => text.clone().yellow(),
};
let line = Line::from(vec![
format!(" {} ", self.state.mode.label()).black().on_cyan().bold(),
format!(" {}{focus} │ {title} │ ", self.view.camera().zoom.name()).into(),
middle,
]);
frame.render_widget(Paragraph::new(line), area);
let typed = Line::from(format!("{} ", self.keys.shown()).cyan().bold());
frame.render_widget(Paragraph::new(typed).right_aligned(), area);
}
fn which_key_due(&self) -> bool {
let delay = if self.keys.first_key() == Some(self.keymap.leader()) {
Duration::ZERO
} else {
WHICH_KEY_DELAY
};
self.which_key && self.keys.pending_since().is_some_and(|since| since.elapsed() >= delay)
}
}
/// What a command does, in words: its own help, or its operator's.
fn describe(command: Command) -> &'static str {
match (help_for(Bound::Command(command)), command) {
("", Command::Operate(operator, _)) => help_for(Bound::Operator(operator)),
(help, _) => help,
}
}
/// A completion popup setting's name, as `:set` takes it.
fn popup_name(popup: PopupMode) -> &'static str {
PopupMode::VALUES.iter().find(|(_, mode)| *mode == popup).map_or("?", |(name, _)| *name)
}
/// The playground driven by typed keys and mouse gestures on a test
/// terminal: what this stage says works, checked end to end.
#[cfg(test)]
mod tests {
use super::*;
use cumulus::input::key::parse_keys;
use cumulus::input::keymap::DEFAULT_LEADER;
use cumulus::input::mouse::Cell;
use cumulus::model::{CanvasPos, NodeId};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
struct Harness {
playground: Playground,
terminal: Terminal<TestBackend>,
}
impl Harness {
fn new() -> Harness {
let doc = document_file::parse(include_str!("garden.cum")).unwrap();
let terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut harness = Harness { playground: Playground::new(doc), terminal };
harness.draw();
harness
}
fn draw(&mut self) {
let Harness { playground, terminal } = self;
terminal.draw(|frame| playground.draw(frame)).unwrap();
}
/// Types keys written in vim notation, drawing after each one as the
/// event loop does.
fn type_keys(&mut self, keys: &str) {
for key in parse_keys(keys, DEFAULT_LEADER).unwrap() {
self.playground.key(key);
self.draw();
}
}
fn mouse(&mut self, gesture: Gesture) {
self.playground.gesture(gesture);
self.draw();
}
/// The title of the cursor's node.
fn cursor(&self) -> String {
let buffer = &self.playground.state.buffer;
buffer.doc().node(buffer.cursor()).unwrap().title().to_string()
}
fn message(&self) -> &str {
self.playground.message.as_ref().map_or("", |message| &message.text)
}
/// The node with this title.
fn node(&self, title: &str) -> NodeId {
let doc = self.playground.state.buffer.doc();
doc.outline().into_iter().find(|&id| doc.node(id).unwrap().title().as_str() == title).unwrap()
}
fn pos(&self, title: &str) -> CanvasPos {
self.playground.state.buffer.doc().node(self.node(title)).unwrap().pos
}
/// What's on screen, row by row.
fn screen(&self) -> String {
let buffer = self.terminal.backend().buffer();
let rows = buffer.content().chunks(usize::from(buffer.area.width));
rows.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>() + "\n").collect()
}
}
#[test]
fn marks_counts_and_undo() {
let mut h = Harness::new();
h.type_keys("glma");
assert_eq!(h.cursor(), "Soil");
assert_eq!(h.message(), "mark a is on \"Soil\": 'a comes back here");
h.type_keys("gg'a");
assert_eq!(h.cursor(), "Soil");
h.type_keys("''");
assert_eq!(h.cursor(), "Garden plans", "'' goes back to before the last jump");
h.type_keys("'a2dd");
assert_eq!(h.message(), "deleted 4 nodes · u to undo", "Soil, and Raised beds with its two");
assert_eq!(h.cursor(), "Watering");
assert!(!h.screen().contains("Raised beds"));
h.type_keys("u");
assert_eq!(h.cursor(), "Soil");
assert!(h.screen().contains("Raised beds"));
}
#[test]
fn move_mode_moves_the_branch_as_one_undo_step() {
let mut h = Harness::new();
h.type_keys("glgjM");
assert_eq!(h.cursor(), "Raised beds");
assert_eq!(h.playground.state.mode.label(), "MOVE");
h.type_keys("3lJ<CR>");
assert_eq!(h.playground.state.mode.label(), "NORMAL");
assert_eq!(h.pos("Raised beds"), CanvasPos::new(25, 12), "3 right, 5 down");
assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(46, 10), "its branch came along");
h.type_keys("u");
assert_eq!(h.pos("Raised beds"), CanvasPos::new(22, 7));
assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(43, 5));
}
#[test]
fn dragging_a_node_moves_its_branch() {
let mut h = Harness::new();
let soil = h.playground.view.camera().to_screen(h.pos("Soil"));
let at =
|col: i32, row: i32| Cell { col: u16::try_from(col).unwrap(), row: u16::try_from(row).unwrap() };
let (start, end) = (at(soil.col + 1, soil.row + 1), at(soil.col + 5, soil.row + 3));
h.mouse(Gesture::Press { at: start, ctrl: false });
assert_eq!(h.cursor(), "Soil", "the press selects");
h.mouse(Gesture::Drag { from: start, at: end, ctrl: false });
h.mouse(Gesture::Release { at: end, dragged: true });
assert_eq!(h.pos("Soil"), CanvasPos::new(26, 4));
h.type_keys("u");
assert_eq!(h.pos("Soil"), CanvasPos::new(22, 2));
}
#[test]
fn command_lines_run_through_the_same_actions() {
let mut h = Harness::new();
h.type_keys(":color teal<CR>");
let root = h.playground.state.buffer.doc().node(h.node("Garden plans")).unwrap();
assert_eq!(root.look.color.as_ref().map(|color| color.as_str()), Some("teal"));
h.type_keys(":in mulch<CR>");
assert_eq!(h.message(), "captured \"mulch\" to the Inbox");
assert_eq!(h.playground.state.buffer.doc().parent(h.node("mulch")), Some(h.node("Inbox")));
assert_eq!(h.cursor(), "Garden plans", "capturing leaves the cursor where it was");
h.type_keys(":find drip<CR>");
assert_eq!(h.cursor(), "Drip line");
assert_eq!(h.message(), "\"drip\": match 1 of 1");
h.type_keys(":set completion_popup=on-tab<CR>");
assert_eq!(h.playground.popup, PopupMode::OnTab);
h.type_keys(":frobnicate<CR>");
assert_eq!(h.playground.message.as_ref().map(|message| message.tone), Some(Tone::Problem));
}
#[test]
fn adding_folding_zooming_and_quitting() {
let mut h = Harness::new();
h.type_keys("a");
assert_eq!(h.cursor(), "new idea");
h.type_keys("u");
assert_eq!(h.cursor(), "Garden plans", "undo goes back to where the change was");
h.type_keys("glgjza");
assert_eq!(h.cursor(), "Raised beds");
assert!(h.screen().contains("+2"), "a folded branch shows how many nodes it hides");
assert!(!h.screen().contains("40 cm high"));
h.type_keys("-");
assert!(h.screen().lines().last().unwrap().contains("Far"), "the status bar shows the zoom");
h.type_keys("q");
assert!(h.playground.quit);
}
#[test]
fn keys_from_later_stages_say_so() {
let mut h = Harness::new();
h.type_keys("yy");
assert_eq!(h.message(), "\"yank (copy)…\" comes in a later stage of the build");
h.type_keys("i");
assert_eq!(h.message(), "\"edit the title\" comes in a later stage of the build");
}
}