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:
1 parent
57ba2c275d
commit
6eedbe0ce2
21 files changed
+2085
-763
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+106
-2
@@ -1,8 +1,31 @@
|
||||
//! The views, one module each. Stage 5 of the build adds the interface they
|
||||
//! all share; for now there's the canvas.
|
||||
//! The views, one module each, all behind the [`View`] trait.
|
||||
//!
|
||||
//! A view looks at the document through a [`ViewContext`] and answers keys
|
||||
//! and the mouse with [`Action`]s; it never changes the document, the cursor
|
||||
//! or the mode itself. What it owns is its own way of looking: the canvas
|
||||
//! keeps its camera, the outline its scroll position. So every view works the
|
||||
//! same way from the outside:
|
||||
//!
|
||||
//! 1. [`View::layout`] fits it to its area (and brings the cursor into view),
|
||||
//! 2. [`View::render`] draws it,
|
||||
//! 3. [`View::command`] and [`View::mouse`] turn input into actions.
|
||||
|
||||
pub mod canvas;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use crate::app::action::Action;
|
||||
use crate::app::mode::Mode;
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::mouse::Gesture;
|
||||
use crate::model::{CanvasRect, CanvasSize, Document, Node, NodeId};
|
||||
use crate::ui::theme::Theme;
|
||||
use canvas::boxes::NodeBox;
|
||||
|
||||
/// The kinds of view a pane can show.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ViewKind {
|
||||
@@ -29,3 +52,84 @@ impl ViewKind {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings every view follows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ViewSettings {
|
||||
/// The width titles wrap at (`max_node_width` in config.toml).
|
||||
pub max_node_width: u16,
|
||||
/// Focus mode: Open branches act like Auto ones.
|
||||
pub focus: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewSettings {
|
||||
fn default() -> ViewSettings {
|
||||
ViewSettings { max_node_width: canvas::boxes::DEFAULT_MAX_WIDTH, focus: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a view can look at while it lays out, draws or handles input.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ViewContext<'a> {
|
||||
pub doc: &'a Document,
|
||||
/// The document's cursor, shared by every pane showing it.
|
||||
pub cursor: NodeId,
|
||||
pub mode: &'a Mode,
|
||||
pub theme: &'a Theme,
|
||||
pub settings: &'a ViewSettings,
|
||||
}
|
||||
|
||||
impl ViewContext<'_> {
|
||||
/// Every node's box on the canvas, at Normal zoom. Every view needs
|
||||
/// these to place new nodes, since every node has a spot on the canvas.
|
||||
pub fn box_rects(&self) -> BTreeMap<NodeId, CanvasRect> {
|
||||
canvas::boxes::box_rects(self.doc, self.settings.max_node_width, self.theme.glyphs)
|
||||
}
|
||||
|
||||
/// The size a node's box has on the canvas.
|
||||
pub fn box_size(&self, node: &Node) -> CanvasSize {
|
||||
NodeBox::new(node, String::new(), self.settings.max_node_width, self.theme.glyphs).rect.size
|
||||
}
|
||||
}
|
||||
|
||||
/// What every view does.
|
||||
pub trait View {
|
||||
/// Which view this is.
|
||||
fn kind(&self) -> ViewKind;
|
||||
|
||||
/// The key tables in use in a mode, most specific first.
|
||||
fn contexts(&self, mode: &Mode) -> &'static [Context];
|
||||
|
||||
/// Fits the view to its area before it's drawn, bringing the cursor into
|
||||
/// view when it moved.
|
||||
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>);
|
||||
|
||||
/// Draws the view as laid out. Drawing changes nothing.
|
||||
fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>);
|
||||
|
||||
/// A command typed while this view has the focus, or `None` when it isn't
|
||||
/// one this view knows.
|
||||
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>>;
|
||||
|
||||
/// A mouse gesture over the view.
|
||||
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use super::*;
|
||||
|
||||
/// What a [`ViewContext`] borrows besides the document, for tests.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Setup {
|
||||
pub mode: Mode,
|
||||
pub theme: Theme,
|
||||
pub settings: ViewSettings,
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
pub fn ctx<'a>(&'a self, doc: &'a Document, cursor: NodeId) -> ViewContext<'a> {
|
||||
ViewContext { doc, cursor, mode: &self.mode, theme: &self.theme, settings: &self.settings }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user