Files
cumulus/examples/canvas.rs
T
skkeyeandClaude Opus 5.5 c2e62de172 Add the key engine, which-key and keys.toml
Keys now work the way vim reads them. A key press becomes a Key (with vim
notation for writing keys: `<C-w>v`, `<leader>c`, `<<`), and the engine
collects counts (`12j`), registers (`"+p`), sequences (`gg`), operators
with their targets (`dd`, `dab`, `2d3d` multiplies to 6) and keys that take
a character (`ma`, `'A`), then hands over a typed Command. Every command
has a name and a help text in one table, and the default keys live in one
table per place they work (motions, normal, canvas, visual, move, connect,
lists).

keys.toml changes keys per section, with the leader configurable and
"nothing" to unbind. Each mistake is skipped and reported in words, with a
suggestion for mistyped command names, and a key that would hide a longer
one is reported too. The which-key popup reads the same tables: it shows
what can follow the keys typed so far, in skyshift's look and place, with
the leader menu instant and other prefixes after 300 ms. The mouse reader
turns button events into presses, double-clicks, drags and scrolls.

`Direction` moved from the glyphs to the model, since input needs it too.
The canvas example now runs on all of this, with a two-line keys.toml of
its own that makes `q` quit.

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

588 lines
23 KiB
Rust

//! A playground for the canvas (stages 2 and 3 of the build): a sample
//! document to move around, zoom, fold and rearrange, with the real vim keys
//! and the which-key popup. The command line, text editing and the other
//! views come in later stages.
//!
//! 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 |
//! | `<Space>` | the action menu (`<Space>w`: key hints on or off) |
//! | `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::input::bindings::Context;
use cumulus::input::command::{Bound, Command, Motion, Operator, Target, help_for};
use cumulus::input::engine::{KeyEngine, Outcome, Typed};
use cumulus::input::key::Key;
use cumulus::input::keymap::Keymap;
use cumulus::input::mouse::{Cell, Gesture, MouseReader};
use cumulus::model::{
CanvasOffset, Change, Direction, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title,
Visible,
};
use cumulus::storage::document_file;
use cumulus::ui::theme::Theme;
use cumulus::ui::which_key::WhichKey;
use cumulus::view::canvas::boxes::{NodeBox, box_rects};
use cumulus::view::canvas::navigate::nearest;
use cumulus::view::canvas::placement::{below_spot, child_spot};
use cumulus::view::canvas::{Camera, Scene, SceneOptions, ScreenPos};
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);
const NORMAL: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions];
const MOVING: &[Context] = &[Context::Move];
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
}
enum Mode {
Normal,
/// Moving the cursor's node: its whole branch, or just the node. The
/// undo step for the whole move builds up as it goes.
Move {
whole: bool,
change: Option<Change>,
},
}
/// A mouse drag in progress.
enum Drag {
/// Moving a node's branch: how far it has moved so far, and the undo
/// step for the whole drag.
Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, change: Option<Change> },
/// Panning the view: the cell the mouse was last on.
Pan { last: ScreenPos },
}
struct Playground {
doc: Document,
history: History,
cursor: NodeId,
camera: Camera,
options: SceneOptions,
theme: Theme,
keymap: Keymap,
keys: KeyEngine,
which_key: bool,
mode: Mode,
mouse: MouseReader,
drag: Option<Drag>,
/// The last frame drawn, to match clicks against.
scene: Scene,
canvas_area: Rect,
/// Set when the cursor moved, so the next frame brings it into view.
follow: bool,
message: String,
}
impl Playground {
fn new(doc: Document) -> Playground {
let (keymap, problems) = Keymap::from_toml(KEYS_TOML);
Playground {
cursor: doc.root(),
doc,
history: History::default(),
camera: Camera::default(),
options: SceneOptions::default(),
theme: Theme::default(),
keymap,
keys: KeyEngine::default(),
which_key: true,
mode: Mode::Normal,
mouse: MouseReader::default(),
drag: None,
scene: Scene::default(),
canvas_area: Rect::default(),
follow: true,
message: problems.join(" · "),
}
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
loop {
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)
{
return Ok(());
}
}
Event::Mouse(event) => {
if let Some(gesture) = self.mouse.read(event, Instant::now()) {
self.gesture(gesture);
}
}
_ => {}
}
}
}
// ----- Drawing --------------------------------------------------------
fn draw(&mut self, frame: &mut Frame) {
let [canvas, status] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
self.canvas_area = canvas;
self.scene = self.build_scene(canvas);
if self.follow {
self.follow = false;
if let Some(rect) = self.scene.rect_of(self.cursor)
&& self.camera.reveal(rect, canvas.as_size(), 3)
{
self.scene = self.build_scene(canvas);
}
}
frame.render_widget(&self.scene, canvas);
if self.which_key_due()
&& let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts()))
{
frame.render_widget(WhichKey { hints: &hints, theme: &self.theme }, canvas);
}
self.draw_status(frame, status);
}
fn build_scene(&self, area: Rect) -> Scene {
Scene::build(&self.doc, self.cursor, self.camera, area.as_size(), &self.theme, self.options)
}
/// 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 (mode, hints) = match self.mode {
Mode::Normal => (
" NORMAL ",
"hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · <Space> menu · q quit",
),
Mode::Move { whole: true, .. } => {
(" MOVE ", "hjkl moves the branch (HJKL by 5) · M just this node · Enter done")
}
Mode::Move { whole: false, .. } => {
(" MOVE ", "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done")
}
};
let title = self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default();
let focus = if self.options.focus { " · focus" } else { "" };
let middle = if self.message.is_empty() { hints.dark_gray() } else { self.message.clone().yellow() };
let line = Line::from(vec![
mode.black().on_cyan().bold(),
format!(" {}{focus} │ {title} │ ", self.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)
}
// ----- Keys -----------------------------------------------------------
fn contexts(&self) -> &'static [Context] {
match self.mode {
Mode::Normal => NORMAL,
Mode::Move { .. } => MOVING,
}
}
/// Handles a key press. Returns false to quit.
fn key(&mut self, key: Key) -> bool {
self.message.clear();
let outcome = self.keys.press(key, &self.keymap.tables(self.contexts()));
match outcome {
Outcome::Run(typed) => return self.run_command(typed),
Outcome::Unknown(keys) => self.message = format!("{keys} does nothing here"),
Outcome::Waiting | Outcome::Cancelled => {}
}
true
}
/// Runs a command. Returns false to quit.
fn run_command(&mut self, typed: Typed) -> bool {
if let Mode::Move { .. } = self.mode {
self.move_command(typed);
return true;
}
let times = typed.times();
let steps = i32::try_from(times).unwrap_or(1);
match typed.command {
Command::Move(motion) => (0..times).for_each(|_| self.motion(motion)),
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => self.delete_branch(),
Command::Operate(Operator::Delete, Target::Children) => self.delete_children(),
Command::DeleteNode => self.delete_node(),
Command::AddChild => self.add(Spot::LastChildOf(self.cursor)),
Command::AddBelow => self.add(Spot::After(self.cursor)),
Command::AddAbove => self.add(Spot::Before(self.cursor)),
Command::Undo => (0..times).for_each(|_| self.undo()),
Command::Redo => (0..times).for_each(|_| self.redo()),
Command::ZoomIn => self.zoom(true),
Command::ZoomOut => self.zoom(false),
Command::ToggleFold => self.toggle_fold(),
Command::OpenFold => self.set_fold(Fold::Open),
Command::CloseFold => self.set_fold(Fold::Closed),
Command::AutoFold => self.set_fold(Fold::Auto),
Command::OpenAllFolds => self.fold_all(Fold::Open),
Command::CloseAllFolds => self.fold_all(Fold::Closed),
Command::ToggleFocus => {
self.options.focus = !self.options.focus;
self.message = format!("focus {}", if self.options.focus { "on" } else { "off" });
}
Command::Center => self.center(),
Command::ScrollDown => self.camera.pan(0, 3 * steps),
Command::ScrollUp => self.camera.pan(0, -3 * steps),
Command::ScrollLeft => self.camera.pan(-6 * steps, 0),
Command::ScrollRight => self.camera.pan(6 * steps, 0),
Command::HalfPageDown => self.camera.pan(0, i32::from(self.canvas_area.height / 2)),
Command::HalfPageUp => self.camera.pan(0, -i32::from(self.canvas_area.height / 2)),
Command::MoveMode => self.mode = Mode::Move { whole: true, change: None },
Command::ToggleWhichKey => {
self.which_key = !self.which_key;
self.message = format!("key hints {}", if self.which_key { "on" } else { "off" });
}
Command::Escape => {}
Command::Close => return false,
other => {
let help = help_for(Bound::Command(other));
self.message = format!("\"{help}\" comes in a later stage of the build");
}
}
true
}
fn motion(&mut self, motion: Motion) {
let doc = &self.doc;
let target = match motion {
Motion::Left => return self.go(Direction::Left),
Motion::Down => return self.go(Direction::Down),
Motion::Up => return self.go(Direction::Up),
Motion::Right => return self.go(Direction::Right),
Motion::Parent => doc.parent(self.cursor),
Motion::FirstChild => doc.children(self.cursor).first().copied(),
Motion::NextSibling => doc.next_sibling(self.cursor),
Motion::PreviousSibling => doc.previous_sibling(self.cursor),
Motion::First => Some(doc.root()),
Motion::Next | Motion::Previous | Motion::Last => {
let order = Visible::new(doc, self.cursor, self.options.focus).order;
let here = order.iter().position(|&id| id == self.cursor).unwrap_or(0);
match motion {
Motion::Next => order.get(here + 1).copied(),
Motion::Previous => here.checked_sub(1).and_then(|index| order.get(index).copied()),
_ => order.last().copied(),
}
}
_ => {
self.message = "search comes in a later stage of the build".into();
return;
}
};
self.jump(target);
}
/// Commands in move mode: hjkl moves the node or its branch.
fn move_command(&mut self, typed: Typed) {
let step = i32::try_from(typed.times()).unwrap_or(1);
let (cols, rows) = match typed.command {
Command::Move(Motion::Left) => (-step, 0),
Command::Move(Motion::Right) => (step, 0),
Command::Move(Motion::Down) => (0, step),
Command::Move(Motion::Up) => (0, -step),
Command::FarLeft => (-5 * step, 0),
Command::FarRight => (5 * step, 0),
Command::FarDown => (0, 5 * step),
Command::FarUp => (0, -5 * step),
Command::ToggleWhole => {
if let Mode::Move { whole, .. } = &mut self.mode {
*whole = !*whole;
}
return;
}
_ => {
if let Mode::Move { change: Some(change), .. } =
std::mem::replace(&mut self.mode, Mode::Normal)
{
self.history.record(change);
}
return;
}
};
let (id, offset) = (self.cursor, self.camera.cells(cols, rows));
let whole = matches!(self.mode, Mode::Move { whole: true, .. });
let result = self.doc.edit(|edit| {
if whole { edit.shift_branch(id, offset) } else { edit.update(id, |node| node.pos += offset) }
});
if let (Ok(((), next)), Mode::Move { change, .. }) = (result, &mut self.mode) {
*change = Some(match change.take() {
Some(so_far) => so_far.then(next),
None => next,
});
self.follow = true;
}
}
// ----- The mouse ------------------------------------------------------
fn gesture(&mut self, gesture: Gesture) {
let area = self.canvas_area;
let screen = |cell: Cell| {
ScreenPos::new(i32::from(cell.col) - i32::from(area.x), i32::from(cell.row) - i32::from(area.y))
};
match gesture {
Gesture::Press { at, .. } => {
let at = screen(at);
self.drag = Some(match self.scene.node_at(at) {
Some(id) => {
self.cursor = id;
Drag::Node { id, from: at, moved: CanvasOffset::default(), change: None }
}
None => Drag::Pan { last: at },
});
}
Gesture::DoublePress { .. } => self.message = "editing titles comes in stage 6".into(),
Gesture::Drag { at, .. } => self.drag_to(screen(at)),
Gesture::Release { .. } => {
if let Some(Drag::Node { change: Some(change), .. }) = self.drag.take() {
self.history.record(change);
}
}
Gesture::Scroll { direction, .. } => match direction {
Direction::Up => self.camera.pan(0, -3),
Direction::Down => self.camera.pan(0, 3),
Direction::Left => self.camera.pan(-6, 0),
Direction::Right => self.camera.pan(6, 0),
},
Gesture::RightPress { .. } => self.message = "the right-click menu comes in stage 7".into(),
}
}
fn drag_to(&mut self, at: ScreenPos) {
match &mut self.drag {
Some(Drag::Node { id, from, moved, change }) => {
let total = self.camera.cells(at.col - from.col, at.row - from.row);
let step = CanvasOffset::new(total.dx - moved.dx, total.dy - moved.dy);
let id = *id;
if step != CanvasOffset::default()
&& let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, step))
{
*moved = total;
*change = Some(match change.take() {
Some(so_far) => so_far.then(next),
None => next,
});
}
}
Some(Drag::Pan { last }) => {
self.camera.pan(last.col - at.col, last.row - at.row);
*last = at;
}
None => {}
}
}
// ----- What the commands do -------------------------------------------
/// Moves the cursor to the nearest node on show in a direction.
fn go(&mut self, direction: Direction) {
let visible = Visible::new(&self.doc, self.cursor, self.options.focus);
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
let Some(&from) = rects.get(&self.cursor) else { return };
let candidates = visible.order.iter().filter_map(|id| Some((*id, *rects.get(id)?)));
match nearest(from, direction, candidates) {
Some(id) => self.jump(Some(id)),
None => self.message = "nothing that way".into(),
}
}
fn jump(&mut self, target: Option<NodeId>) {
match target {
Some(id) => {
self.cursor = id;
self.follow = true;
}
None => self.message = "nothing there".into(),
}
}
fn zoom(&mut self, closer: bool) {
let zoom = if closer { self.camera.zoom.closer() } else { self.camera.zoom.further() };
if let Some(node) = self.doc.node(self.cursor) {
self.camera.set_zoom(zoom, node.pos);
}
self.follow = true;
}
fn center(&mut self) {
if let Some(node) = self.doc.node(self.cursor) {
self.camera.center_on(node.pos, self.canvas_area.as_size());
}
}
fn toggle_fold(&mut self) {
let Some(node) = self.doc.node(self.cursor) else { return };
let fold = match node.fold {
Fold::Closed => Fold::Open,
Fold::Open | Fold::Auto => Fold::Closed,
};
self.set_fold(fold);
}
fn set_fold(&mut self, fold: Fold) {
let id = self.cursor;
self.edit(|edit| edit.update(id, |node| node.fold = fold));
self.message = format!("fold: {fold:?}");
}
fn fold_all(&mut self, fold: Fold) {
let parents: Vec<NodeId> =
self.doc.outline().into_iter().filter(|&id| !self.doc.children(id).is_empty()).collect();
self.edit(|edit| parents.iter().try_for_each(|&id| edit.update(id, |node| node.fold = fold)));
}
/// Adds a "new idea" node at `spot`, placed next to its parent or
/// sibling, and moves the cursor to it.
fn add(&mut self, spot: Spot) {
let Ok(title) = Title::parse("new idea") else { return };
let node = Node::idea(title, Default::default());
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
let size = NodeBox::new(&node, String::new(), self.options.max_width, self.theme.glyphs).rect.size;
let pos = match spot {
Spot::LastChildOf(parent) => child_spot(&self.doc, parent, size, &rects),
_ => below_spot(self.cursor, size, &rects),
};
let added = self.edit(|edit| edit.add(Node { pos, ..node }, spot));
self.jump(added);
}
fn delete_node(&mut self) {
let id = self.cursor;
let next = self.doc.children(id).first().copied().or_else(|| self.doc.parent(id));
if self.edit(|edit| edit.remove_keeping_children(id)).is_some() {
self.cursor = next.unwrap_or(self.doc.root());
}
}
fn delete_branch(&mut self) {
let id = self.cursor;
let next = self.doc.parent(id).or_else(|| self.doc.previous_sibling(id));
if let Some(removed) = self.edit(|edit| edit.remove_branch(id)) {
self.cursor = next.unwrap_or(self.doc.root());
self.message = format!("deleted {} node(s) · u to undo", removed.len());
}
}
fn delete_children(&mut self) {
let children = self.doc.children(self.cursor).to_vec();
let removed = self.edit(|edit| {
let mut count = 0;
for &child in &children {
count += edit.remove_branch(child)?.len();
}
Ok(count)
});
if let Some(count) = removed {
self.message = format!("deleted {count} node(s) · u to undo");
}
}
fn undo(&mut self) {
let touched: Vec<NodeId> = match self.history.undo(&mut self.doc) {
Some(change) => change.nodes().collect(),
None => {
self.message = "nothing to undo".into();
return;
}
};
self.land_on(&touched);
}
fn redo(&mut self) {
let touched: Vec<NodeId> = match self.history.redo(&mut self.doc) {
Some(change) => change.nodes().collect(),
None => {
self.message = "nothing to redo".into();
return;
}
};
self.land_on(&touched);
}
/// After undo or redo: the cursor goes where the change happened.
fn land_on(&mut self, touched: &[NodeId]) {
let target = touched.iter().copied().find(|id| self.doc.contains(*id)).unwrap_or(self.doc.root());
self.jump(Some(target));
}
/// Runs an edit and records it for undo; shows the error if it's refused.
fn edit<T>(&mut self, steps: impl FnOnce(&mut Edit<'_>) -> Result<T, EditError>) -> Option<T> {
match self.doc.edit(steps) {
Ok((value, change)) => {
self.history.record(change);
Some(value)
}
Err(error) => {
self.message = error.to_string();
None
}
}
}
}