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]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 04:46:05 -04:00
1 parent 57ba2c275d
commit 6eedbe0ce2
21 files changed
+2085 -763

No files matched your search

+6 -1
View File
@@ -9,6 +9,9 @@
//! 2. Rendering the scene (in `render.rs`) writes it into the terminal.
//! 3. Clicks are looked up in the same scene ([`Scene::node_at`]); hjkl uses
//! [`navigate`], and [`placement`] decides where new nodes go.
//!
//! [`CanvasView`] puts it together behind the [`View`](super::View) trait: it
//! keeps the camera and the last scene, and answers keys and the mouse.
pub mod boxes;
pub mod camera;
@@ -18,7 +21,9 @@ pub mod placement;
mod render;
pub mod route;
pub mod scene;
mod view;
pub use camera::{Camera, ScreenPos, ScreenRect, Zoom};
pub use lines::LineOwner;
pub use scene::{Scene, SceneNode, SceneOptions, Shape};
pub use scene::{Scene, SceneNode, Shape};
pub use view::CanvasView;
+16 -6
View File
@@ -19,12 +19,22 @@ pub fn child_spot(
size: CanvasSize,
rects: &BTreeMap<NodeId, CanvasRect>,
) -> CanvasPos {
let Some(parent_rect) = rects.get(&parent) else { return CanvasPos::default() };
let start = match doc.children(parent).last().and_then(|child| rects.get(child)) {
Some(last) => CanvasPos::new(last.left(), last.bottom() + ROW_GAP),
None => CanvasPos::new(parent_rect.right() + BRANCH_GAP, parent_rect.top()),
};
free_spot(start, size, rects)
let Some(&parent_rect) = rects.get(&parent) else { return CanvasPos::default() };
match doc.children(parent).last().and_then(|child| rects.get(child)) {
Some(last) => free_spot(CanvasPos::new(last.left(), last.bottom() + ROW_GAP), size, rects),
None => first_child_spot(parent_rect, size, rects),
}
}
/// Where the first child of a box goes: to its right, level with it, then
/// further down until it's clear. The box doesn't need to be in `rects` yet,
/// so this works for a parent that's being added in the same edit.
pub fn first_child_spot(
parent: CanvasRect,
size: CanvasSize,
rects: &BTreeMap<NodeId, CanvasRect>,
) -> CanvasPos {
free_spot(CanvasPos::new(parent.right() + BRANCH_GAP, parent.top()), size, rects)
}
/// Where a new node just after `sibling` goes: right below it, then further
+7 -6
View File
@@ -171,7 +171,8 @@ mod tests {
use crate::model::test_support::garden;
use crate::model::{CanvasPos, Fold, Label, Link};
use crate::ui::theme::Theme;
use crate::view::canvas::{Camera, SceneOptions, Zoom};
use crate::view::ViewSettings;
use crate::view::canvas::{Camera, Zoom};
/// Prints the example document (`examples/garden.cum`) at each zoom, to
/// look at the canvas without a terminal:
@@ -188,7 +189,7 @@ mod tests {
camera,
Size::new(84, 36),
&Theme::default(),
SceneOptions::default(),
&ViewSettings::default(),
);
println!("── {} {}", zoom.name(), "─".repeat(70));
for row in snapshot(&scene) {
@@ -217,7 +218,7 @@ mod tests {
Camera::default(),
Size::new(52, 17),
&Theme::default(),
SceneOptions::default(),
&ViewSettings::default(),
);
assert_eq!(
snapshot(&scene),
@@ -251,7 +252,7 @@ mod tests {
doc.edit(|e| e.update(g.beds, |node| node.look.color = Some(orange.clone()))).unwrap();
let theme = Theme::default();
let scene =
Scene::build(&doc, g.soil, Camera::default(), Size::new(52, 8), &theme, SceneOptions::default());
Scene::build(&doc, g.soil, Camera::default(), Size::new(52, 8), &theme, &ViewSettings::default());
let area = Rect::new(0, 0, 52, 8);
let mut buf = Buffer::empty(area);
scene.render(area, &mut buf);
@@ -277,7 +278,7 @@ mod tests {
Camera::default(),
Size::new(52, 19),
&Theme::default(),
SceneOptions::default(),
&ViewSettings::default(),
);
let text = snapshot(&scene);
assert!(text[6].ends_with("── +1 ─┘"), "the hidden Cedar shows as +1: {text:#?}");
@@ -288,7 +289,7 @@ mod tests {
// the compost note up into Watering, labeled where it runs level.
let far = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Far };
let scene =
Scene::build(&doc, g.root, far, Size::new(40, 9), &Theme::default(), SceneOptions::default());
Scene::build(&doc, g.root, far, Size::new(40, 9), &Theme::default(), &ViewSettings::default());
assert_eq!(
snapshot(&scene),
vec![
+6 -19
View File
@@ -8,32 +8,18 @@ use ratatui::layout::Size;
use ratatui::style::Color;
use unicode_width::UnicodeWidthStr;
use super::boxes::{DEFAULT_MAX_WIDTH, NodeBox, truncate};
use super::boxes::{NodeBox, truncate};
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
use super::lines::{LineLayer, LineOwner, Paint};
use super::route::{Bend, route};
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
use crate::ui::glyphs::Glyphs;
use crate::ui::theme::{LineStyle, Theme};
use crate::view::ViewSettings;
/// How many columns of a title show at Far zoom.
const FAR_TITLE_WIDTH: usize = 14;
/// What a scene needs besides the document, the cursor and the camera.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SceneOptions {
/// The width titles wrap at.
pub max_width: u16,
/// Focus mode: Open branches act like Auto ones.
pub focus: bool,
}
impl Default for SceneOptions {
fn default() -> SceneOptions {
SceneOptions { max_width: DEFAULT_MAX_WIDTH, focus: false }
}
}
/// How a node is drawn at the camera's zoom.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Shape {
@@ -95,9 +81,9 @@ impl Scene {
camera: Camera,
size: Size,
theme: &Theme,
options: SceneOptions,
settings: &ViewSettings,
) -> Scene {
let visible = Visible::new(doc, cursor, options.focus);
let visible = Visible::new(doc, cursor, settings.focus);
let mut scene = Scene {
size,
zoom: camera.zoom,
@@ -112,7 +98,8 @@ impl Scene {
let Some(node) = doc.node(id) else { continue };
let style = theme.node_style(node, id == doc.root());
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default();
let (rect, shape) = shape(node, badge, style.border, camera, theme.glyphs, options.max_width);
let (rect, shape) =
shape(node, badge, style.border, camera, theme.glyphs, settings.max_node_width);
scene.nodes.push(SceneNode { id, rect, shape, color: style.color, selected: id == cursor });
}
// The cursor's node goes last, on top of any it overlaps.
+373
View File
@@ -0,0 +1,373 @@
//! The canvas as a [`View`]. It owns the camera and the last scene it laid
//! out; everything else it reads from the [`ViewContext`], and every change
//! it wants to the document goes out as an action.
use ratatui::Frame;
use ratatui::layout::Rect;
use super::camera::{Camera, ScreenPos, Zoom};
use super::navigate::nearest;
use super::scene::Scene;
use crate::app::action::{Action, AppAction, Message};
use crate::app::mode::{Grab, Mode};
use crate::input::bindings::Context;
use crate::input::command::{Command, Motion};
use crate::input::engine::Typed;
use crate::input::mouse::{Cell, Gesture};
use crate::model::{CanvasOffset, Direction, EditOp, Group, NodeId, Visible};
use crate::view::{View, ViewContext, ViewKind};
/// How close (in cells) the cursor's node may come to an edge before the
/// view follows it, like vim's `scrolloff`.
const SCROLL_OFF: i32 = 3;
/// How far `Ctrl-e`, `Ctrl-y` and the mouse wheel scroll.
const SCROLL_ROWS: i32 = 3;
/// How far `zh`, `zl` and the wheel sideways scroll: cells are about twice
/// as tall as they're wide, so this is about as far as `SCROLL_ROWS`.
const SCROLL_COLS: i32 = 6;
/// How many cells `HJKL` move a node in move mode (`hjkl` move it one).
const FAR: i32 = 5;
/// A mouse drag in progress.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Drag {
/// Moving a node with its branch: where the drag started, and how far
/// the branch has moved so far. The whole drag is one undo step.
Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, group: Group },
/// Panning the view: the cell the mouse was last on.
Pan { last: ScreenPos },
}
/// The canvas view.
#[derive(Debug, Default)]
pub struct CanvasView {
camera: Camera,
/// The frame laid out last: what's drawn, and what a click lands on.
scene: Scene,
/// Where on screen the view is.
area: Rect,
/// The cursor at the last layout. When it moves, the view follows.
last_cursor: Option<NodeId>,
/// Bring the cursor's node into view at the next layout, though the
/// cursor didn't move: after a zoom, or when move mode moves the node.
reveal: bool,
drag: Option<Drag>,
}
impl CanvasView {
pub fn new() -> CanvasView {
CanvasView::default()
}
/// The camera (the status bar shows its zoom).
pub fn camera(&self) -> Camera {
self.camera
}
fn build(&self, ctx: &ViewContext<'_>) -> Scene {
Scene::build(ctx.doc, ctx.cursor, self.camera, self.area.as_size(), ctx.theme, ctx.settings)
}
/// hjkl: the nearest node on show that way, `times` times over. Folds are
/// worked out again at each step, since an Auto fold opens when the
/// cursor goes inside it.
fn go(&self, direction: Direction, times: u32, ctx: &ViewContext<'_>) -> Vec<Action> {
let rects = ctx.box_rects();
let mut at = ctx.cursor;
for _ in 0..times {
let Some(&from) = rects.get(&at) else { break };
let shown = Visible::new(ctx.doc, at, ctx.settings.focus).order;
match nearest(from, direction, shown.iter().filter_map(|id| Some((*id, *rects.get(id)?)))) {
Some(next) => at = next,
None => break,
}
}
if at == ctx.cursor {
vec![Action::say(Message::info("nothing that way"))]
} else {
vec![Action::cursor(at)]
}
}
/// Zooms, keeping the cursor's node where it is on screen.
fn zoom(&mut self, zoom: Zoom, ctx: &ViewContext<'_>) {
if let Some(node) = ctx.doc.node(ctx.cursor) {
self.camera.set_zoom(zoom, node.pos);
}
self.reveal = true;
}
/// `zz`: the middle of the cursor's node to the middle of the view.
fn center(&mut self, ctx: &ViewContext<'_>) {
if let Some(rect) = self.scene.rect_of(ctx.cursor) {
let (width, height) = (i32::from(self.area.width), i32::from(self.area.height));
self.camera.pan(rect.mid_col() - width / 2, rect.mid_row() - height / 2);
}
}
/// Move mode: hjkl moves the cursor's node, or its whole branch, and
/// every move until the mode ends is one undo step.
fn move_command(&mut self, typed: &Typed, grab: Grab, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
let step = cells(typed);
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 => (-FAR * step, 0),
Command::FarRight => (FAR * step, 0),
Command::FarDown => (0, FAR * step),
Command::FarUp => (0, -FAR * step),
Command::ToggleWhole => return Some(vec![Action::App(AppAction::ToggleWhole)]),
Command::Confirm => return Some(vec![Action::App(AppAction::LeaveMode)]),
_ => return None,
};
self.reveal = true;
let op = EditOp::Shift { id: ctx.cursor, offset: self.camera.cells(cols, rows), branch: grab.whole };
Some(vec![Action::edit_in(op, grab.group)])
}
/// The view's cell under a terminal cell.
fn screen(&self, cell: Cell) -> ScreenPos {
ScreenPos::new(
i32::from(cell.col) - i32::from(self.area.x),
i32::from(cell.row) - i32::from(self.area.y),
)
}
fn drag_to(&mut self, at: ScreenPos) -> Vec<Action> {
match &mut self.drag {
Some(Drag::Node { id, from, moved, group }) => {
let total = self.camera.cells(at.col - from.col, at.row - from.row);
// Each step moves the branch from where the last one left it.
let step = CanvasOffset::new(total.dx - moved.dx, total.dy - moved.dy);
if step == CanvasOffset::default() {
return Vec::new();
}
*moved = total;
vec![Action::edit_in(EditOp::Shift { id: *id, offset: step, branch: true }, *group)]
}
Some(Drag::Pan { last }) => {
self.camera.pan(last.col - at.col, last.row - at.row);
*last = at;
Vec::new()
}
None => Vec::new(),
}
}
}
/// A command's count, as a number of cells to move or scroll by.
fn cells(typed: &Typed) -> i32 {
// The key engine keeps counts under 10 000, so this always fits.
i32::try_from(typed.times()).unwrap_or(1)
}
/// The direction of hjkl, or `None` for the other motions.
fn direction(motion: Motion) -> Option<Direction> {
match motion {
Motion::Left => Some(Direction::Left),
Motion::Down => Some(Direction::Down),
Motion::Up => Some(Direction::Up),
Motion::Right => Some(Direction::Right),
_ => None,
}
}
impl View for CanvasView {
fn kind(&self) -> ViewKind {
ViewKind::Canvas
}
fn contexts(&self, mode: &Mode) -> &'static [Context] {
match mode {
Mode::Normal => &[Context::Canvas, Context::Normal, Context::Motions],
Mode::Move(_) => &[Context::Move],
// The command line reads its own keys.
Mode::Command(_) => &[],
}
}
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
self.area = area;
let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor);
// While a node is dragged, the view stays put under the mouse.
let follow = std::mem::take(&mut self.reveal) || (moved && self.drag.is_none());
self.scene = self.build(ctx);
if follow
&& let Some(rect) = self.scene.rect_of(ctx.cursor)
&& self.camera.reveal(rect, area.as_size(), SCROLL_OFF)
{
self.scene = self.build(ctx);
}
}
fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) {
frame.render_widget(&self.scene, self.area);
}
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
if let Mode::Move(grab) = ctx.mode {
return self.move_command(typed, *grab, ctx);
}
let steps = cells(typed);
let half_page = i32::from(self.area.height / 2);
match typed.command {
Command::Move(motion) => return direction(motion).map(|way| self.go(way, typed.times(), ctx)),
Command::ZoomIn => self.zoom(self.camera.zoom.closer(), ctx),
Command::ZoomOut => self.zoom(self.camera.zoom.further(), ctx),
Command::Center => self.center(ctx),
Command::ScrollDown => self.camera.pan(0, SCROLL_ROWS * steps),
Command::ScrollUp => self.camera.pan(0, -SCROLL_ROWS * steps),
Command::ScrollLeft => self.camera.pan(-SCROLL_COLS * steps, 0),
Command::ScrollRight => self.camera.pan(SCROLL_COLS * steps, 0),
Command::HalfPageDown => self.camera.pan(0, half_page),
Command::HalfPageUp => self.camera.pan(0, -half_page),
_ => return None,
}
// The rest only move the camera, which is the view's own.
Some(Vec::new())
}
fn mouse(&mut self, gesture: Gesture, _ctx: &ViewContext<'_>) -> Vec<Action> {
match gesture {
Gesture::Press { at, .. } => {
let at = self.screen(at);
match self.scene.node_at(at) {
Some(id) => {
let moved = CanvasOffset::default();
self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() });
vec![Action::cursor(id)]
}
None => {
self.drag = Some(Drag::Pan { last: at });
Vec::new()
}
}
}
Gesture::Drag { at, .. } => self.drag_to(self.screen(at)),
Gesture::Release { .. } => {
self.drag = None;
Vec::new()
}
Gesture::Scroll { direction, .. } => {
let (cols, rows) = match direction {
Direction::Up => (0, -SCROLL_ROWS),
Direction::Down => (0, SCROLL_ROWS),
Direction::Left => (-SCROLL_COLS, 0),
Direction::Right => (SCROLL_COLS, 0),
};
self.camera.pan(cols, rows);
Vec::new()
}
Gesture::DoublePress { .. } => {
vec![Action::say(Message::info("editing titles comes in stage 6"))]
}
Gesture::RightPress { .. } => {
vec![Action::say(Message::info("the right-click menu comes in stage 7"))]
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::action::DocAction;
use crate::app::buffer::Buffer;
use crate::model::CanvasPos;
use crate::model::test_support::garden;
use crate::view::test_support::Setup;
fn typed(command: Command, count: Option<u32>) -> Typed {
Typed { command, count, register: None }
}
/// The terminal cell over a canvas point, with the view at the top-left
/// of the screen and the camera where it starts.
fn cell(pos: CanvasPos) -> Cell {
Cell { col: pos.x as u16, row: pos.y as u16 }
}
fn pos(buffer: &Buffer, id: NodeId) -> CanvasPos {
buffer.doc().node(id).unwrap().pos
}
#[test]
fn hjkl_with_a_count_goes_step_by_step() {
let ((doc, g), setup) = (garden(), Setup::default());
let mut view = CanvasView::new();
// Garden plans → Raised beds → Cedar, all on one row.
let right = typed(Command::Move(Motion::Right), Some(2));
assert_eq!(view.command(&right, &setup.ctx(&doc, g.root)), Some(vec![Action::cursor(g.cedar)]));
let left = typed(Command::Move(Motion::Left), None);
assert_eq!(
view.command(&left, &setup.ctx(&doc, g.root)),
Some(vec![Action::say(Message::info("nothing that way"))])
);
}
#[test]
fn move_mode_moves_the_branch_as_one_undo_step() {
let (doc, g) = garden();
let grab = Grab::new();
let setup = Setup { mode: Mode::Move(grab), ..Setup::default() };
let mut view = CanvasView::new();
let mut buffer = Buffer::new(doc);
buffer.apply(DocAction::MoveCursor { to: g.beds, jump: false });
for command in [Command::Move(Motion::Right), Command::FarDown] {
for action in view.command(&typed(command, Some(2)), &setup.ctx(buffer.doc(), g.beds)).unwrap() {
let Action::Doc(action) = action else { panic!("only edits") };
buffer.apply(action);
}
}
assert_eq!(pos(&buffer, g.beds), CanvasPos::new(24, 14), "2 right, 10 down");
assert_eq!(pos(&buffer, g.cedar), CanvasPos::new(42, 14), "its branch came along");
buffer.apply(DocAction::Undo(1));
assert_eq!(pos(&buffer, g.beds), CanvasPos::new(22, 4), "one undo puts it all back");
assert!(!buffer.history().can_undo());
}
#[test]
fn dragging_a_node_moves_its_branch() {
let ((doc, g), setup) = (garden(), Setup::default());
let mut view = CanvasView::new();
let mut buffer = Buffer::new(doc);
view.layout(Rect::new(0, 0, 80, 20), &setup.ctx(buffer.doc(), buffer.cursor()));
let start = cell(CanvasPos::new(25, 5));
let gestures = [
Gesture::Press { at: start, ctrl: false },
Gesture::Drag { from: start, at: Cell { col: 27, row: 5 }, ctrl: false },
Gesture::Drag { from: start, at: Cell { col: 30, row: 6 }, ctrl: false },
Gesture::Release { at: Cell { col: 30, row: 6 }, dragged: true },
];
for gesture in gestures {
for action in view.mouse(gesture, &setup.ctx(buffer.doc(), buffer.cursor())) {
let Action::Doc(action) = action else { panic!("only document actions") };
buffer.apply(action);
}
}
assert_eq!(buffer.cursor(), g.beds, "the press selected it");
assert_eq!(pos(&buffer, g.beds), CanvasPos::new(27, 5));
assert_eq!(pos(&buffer, g.cedar), CanvasPos::new(45, 5));
buffer.apply(DocAction::Undo(1));
assert_eq!(pos(&buffer, g.beds), CanvasPos::new(22, 4), "one undo step");
}
#[test]
fn the_view_follows_the_cursor_but_not_a_scroll() {
let ((doc, g), setup) = (garden(), Setup::default());
let mut view = CanvasView::new();
let area = Rect::new(0, 0, 30, 12);
view.layout(area, &setup.ctx(&doc, g.root));
// Cedar's box starts 40 columns in: off the right of a 30-column view.
view.layout(area, &setup.ctx(&doc, g.cedar));
let rect = view.scene.rect_of(g.cedar).unwrap();
assert!(rect.left() >= SCROLL_OFF && rect.right() <= 30 - SCROLL_OFF, "{rect:?}");
// Scrolling takes the view away from the cursor, and it stays away.
let before = view.camera().origin;
view.command(&typed(Command::ScrollRight, None), &setup.ctx(&doc, g.cedar));
view.layout(area, &setup.ctx(&doc, g.cedar));
assert_eq!(view.camera().origin, CanvasPos::new(before.x + SCROLL_COLS, before.y));
}
}