Files
cumulus/examples/canvas.rs
T
skkeyeandClaude Opus 5.5 57ba2c275d Make marks and counts work, and the completion popup a setting
Marks: the engine read `ma` and `'a`, but the canvas example only said
they'd come later. It now sets and jumps to marks, and `''` goes back to
where the cursor was before the last jump (a mark, gg, G or :find).
Which-key lists `'` after `'`.

Counts on operators now mean something on nodes: `3dd` deletes this node
and the next two siblings, with their branches, the way vim's `3dd`
deletes three lines (Document::siblings_from). Counts before the operator
and before the target still multiply, and which-key now says what they
add up to: after `2d3`, the `d` entry reads "6 nodes: this one and the
next 5 siblings, with their branches".

The completion popup can now wait for Tab, like vim's wildmenu: the
`completion_popup` setting is "as-you-type" (the default) or "on-tab".
The config file comes with the app core; until then the example takes
`:set completion_popup=on-tab`.

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

856 lines
35 KiB
Rust

//! A playground for the canvas (stages 2 to 4 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.
//!
//! 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) |
//! | `:` | 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::collections::BTreeMap;
use std::io;
use std::time::{Duration, Instant};
use cumulus::command::history::History as CommandHistory;
use cumulus::command::parse::{Call, SetArg, Sources, TagChange, parse};
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, CanvasPos, CanvasSize, Change, Content, Direction, Document, Edit, EditError, Fold,
History, Link, LinkKey, Node, NodeId, Spot, Title, Visible,
};
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::boxes::{NodeBox, box_rects};
use cumulus::view::canvas::navigate::nearest;
use cumulus::view::canvas::placement::{below_spot, child_spot, free_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 command line, while it's open.
command_line: Option<CommandLine>,
command_history: CommandHistory,
/// When the command line's popup shows (`:set completion_popup=on-tab`).
popup: PopupMode,
/// Marked nodes (`ma`), and where the cursor was before the last jump
/// (for `''`).
marks: BTreeMap<char, NodeId>,
before_jump: Option<NodeId>,
/// 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,
command_line: None,
command_history: CommandHistory::default(),
popup: PopupMode::default(),
marks: BTreeMap::new(),
before_jump: 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);
// While the command line is open, it takes the status line's place.
if let Some(line) = &self.command_line {
line.render(frame, status, canvas, &self.theme);
return;
}
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 · : command · <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();
if self.command_line.is_some() {
return self.command_line_key(key);
}
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_branches(times)
}
Command::SetMark(name) => {
self.marks.insert(name, self.cursor);
let title =
self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default();
self.message = format!("mark {name} is on \"{title}\" ('{name} comes back here)");
}
Command::JumpToMark('\'' | '`') => match self.before_jump {
Some(id) if self.doc.contains(id) => self.leap(Some(id)),
_ => self.message = "no jump to go back from yet".into(),
},
Command::JumpToMark(name) => match self.marks.get(&name) {
Some(&id) if self.doc.contains(id) => self.leap(Some(id)),
Some(_) => self.message = format!("the node marked {name} is gone"),
None => self.message = format!("no mark {name} yet: m{name} sets it"),
},
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::CommandLine => {
self.command_line =
Some(CommandLine::new(&self.sources(), &self.command_history, self.popup));
}
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;
}
};
// gg and G are jumps, as in vim: '' comes back from them.
if matches!(motion, Motion::First | Motion::Last) {
self.leap(target);
} else {
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 command line -----------------------------------------------
/// A key while the command line is open. Returns false to quit.
fn command_line_key(&mut self, key: Key) -> bool {
let sources = self.sources();
let Some(line) = &mut self.command_line else { return true };
match line.key(key, &sources, &self.command_history) {
LineOutcome::Typing => true,
LineOutcome::Close => {
self.command_line = None;
true
}
LineOutcome::Run(text) => {
self.command_line = None;
if text.trim().is_empty() {
return true;
}
self.command_history.add(&text);
match parse(&text, &sources) {
Ok(call) => self.run_call(call),
Err(error) => {
self.message = error.to_string();
true
}
}
}
}
}
/// What the command line can name: this document, its nodes, the
/// palette's colors, its tags.
fn sources(&self) -> Sources {
let doc = &self.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.theme.palette.keys().map(ToString::to_string).collect(),
styles: self.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(),
}
}
/// Runs a command line. Returns false to quit.
fn run_call(&mut self, call: Call) -> bool {
let id = self.cursor;
match call {
Call::Quit { .. } | Call::QuitAll { .. } | Call::WriteQuit | Call::ClosePane => return false,
Call::Fold(fold) => self.set_fold(fold),
Call::Focus => {
self.options.focus = !self.options.focus;
self.message = format!("focus {}", if self.options.focus { "on" } else { "off" });
}
Call::Color(color) => {
self.edit(|edit| edit.update(id, |node| node.look.color = color));
}
Call::Border(border) => {
self.edit(|edit| edit.update(id, |node| node.look.border = Some(border)));
}
Call::Tags(changes) => {
self.edit(|edit| {
edit.update(id, |node| {
for change in changes {
match change {
TagChange::Add(tag) => node.tags.insert(tag),
TagChange::Remove(tag) => node.tags.remove(&tag),
};
}
})
});
}
Call::Rename(title) => {
let root = self.doc.root();
self.edit(|edit| {
edit.update(root, |node| {
if let Content::Idea(idea) = &mut node.content {
idea.title = title;
}
})
});
}
Call::Capture(text) => self.capture(&text),
Call::Link(target) => match Link::new(id, target) {
Some(link) => {
self.edit(|edit| edit.link(link));
}
None => self.message = "a node can't link to itself".into(),
},
Call::Unlink(target) => {
let keys: Vec<LinkKey> = match target.and_then(|target| LinkKey::new(id, target)) {
Some(key) => vec![key],
None => self.doc.links_of(id).map(Link::key).collect(),
};
self.edit(|edit| keys.iter().try_for_each(|&key| edit.unlink(key).map(drop)));
}
Call::Find(text) => self.find(&text),
Call::Version => self.message = cumulus::version(),
Call::Set(SetArg::Value(name, value)) if name == "completion_popup" => {
match PopupMode::named(&value) {
Some(popup) => {
self.popup = popup;
self.message = format!("completion_popup = {value}");
}
None => {
let values: Vec<&str> = PopupMode::VALUES.iter().map(|(name, _)| *name).collect();
self.message = format!("completion_popup can be {}", values.join(" or "));
}
}
}
Call::Set(SetArg::Reset(name)) if name == "completion_popup" => {
self.popup = PopupMode::default();
self.message = "completion_popup = as-you-type (the default)".into();
}
Call::Set(SetArg::Show(name)) if name == "completion_popup" => {
let current = PopupMode::VALUES.iter().find(|(_, mode)| *mode == self.popup);
self.message = format!("completion_popup = {}", current.map_or("?", |(name, _)| *name));
}
Call::Help(_) => {
self.message = "the help screen comes in stage 7; Tab completes any command".into()
}
other => self.message = format!("{other:?} comes in a later stage of the build"),
}
true
}
/// `:in`: adds an idea under the Inbox, a loose node made the first time.
fn capture(&mut self, text: &str) {
let Ok(title) = Title::parse(text) else { return };
let inbox = self
.doc
.loose()
.iter()
.copied()
.find(|&id| self.doc.node(id).is_some_and(|node| node.title().as_str() == "Inbox"));
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
let below_everything = rects.values().map(|rect| rect.bottom()).max().unwrap_or(0) + 2;
let size = CanvasSize::new(30, 3);
let added = self.edit(|edit| {
let inbox = match inbox {
Some(inbox) => inbox,
None => {
let pos = free_spot(CanvasPos::new(0, below_everything), size, &rects);
let title = Title::parse("Inbox").map_err(|_| EditError::Full)?;
edit.add(Node::idea(title, pos), Spot::Loose)?
}
};
let pos = child_spot(edit.doc(), inbox, size, &rects);
edit.add(Node::idea(title, pos), Spot::LastChildOf(inbox))
});
if added.is_some() {
self.message = format!("captured \"{text}\" to the Inbox");
}
}
/// `:find`: goes to the next node whose title has the text in it.
fn find(&mut self, text: &str) {
let wanted = text.to_lowercase();
let order = self.doc.outline();
let here = order.iter().position(|&id| id == self.cursor).unwrap_or(0);
let matches: Vec<NodeId> = order
.iter()
.copied()
.filter(|&id| {
self.doc.node(id).is_some_and(|node| node.title().as_str().to_lowercase().contains(&wanted))
})
.collect();
let next = matches.iter().copied().find(|&id| order.iter().position(|&o| o == id) > Some(here));
match next.or_else(|| matches.first().copied()) {
Some(id) => {
self.leap(Some(id));
self.message = format!("{} match(es) for \"{text}\"", matches.len());
}
None => self.message = format!("nothing matches \"{text}\""),
}
}
// ----- 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(),
}
}
/// A jump `''` can come back from (to a mark, `gg`, `G`, a search).
fn leap(&mut self, target: Option<NodeId>) {
if target.is_some() {
self.before_jump = Some(self.cursor);
}
self.jump(target);
}
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());
}
}
/// `dd` (or `dab`), with a count: this node and the next siblings, each
/// with its branch, as `3dd` deletes three lines in vim.
fn delete_branches(&mut self, count: u32) {
let branches = self.doc.siblings_from(self.cursor, usize::try_from(count).unwrap_or(1));
let Some(&last) = branches.last() else { return };
// The cursor goes to the sibling after them, or before them, or up.
let next = self
.doc
.next_sibling(last)
.or_else(|| self.doc.previous_sibling(self.cursor))
.or_else(|| self.doc.parent(self.cursor));
let removed = self.edit(|edit| {
let mut removed = 0;
for &branch in &branches {
removed += edit.remove_branch(branch)?.len();
}
Ok(removed)
});
if let Some(removed) = removed {
self.cursor = next.unwrap_or(self.doc.root());
let what = if branches.len() == 1 {
"1 branch".to_string()
} else {
format!("{} branches", branches.len())
};
self.message = format!("deleted {what}, {removed} node(s) in all · u to undo");
}
}
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
}
}
}
}