From 6eedbe0ce2fbb45c396dc6e6bea514e75531d140 Mon Sep 17 00:00:00 2001 From: Skkeye Date: Thu, 1 Oct 2026 04:46:05 -0400 Subject: [PATCH] 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 --- Cargo.toml | 5 + DESIGN.md | 53 +- README.md | 1 + examples/canvas.rs | 1104 +++++++++++++--------------------- src/app/action.rs | 133 ++++ src/app/buffer.rs | 190 ++++++ src/app/calls.rs | 198 ++++++ src/app/commands.rs | 230 +++++++ src/app/mod.rs | 18 + src/app/mode.rs | 51 ++ src/lib.rs | 2 + src/model/edit.rs | 11 +- src/model/history.rs | 95 ++- src/model/mod.rs | 4 +- src/model/op.rs | 205 +++++++ src/view/canvas/mod.rs | 7 +- src/view/canvas/placement.rs | 22 +- src/view/canvas/render.rs | 13 +- src/view/canvas/scene.rs | 25 +- src/view/canvas/view.rs | 373 ++++++++++++ src/view/mod.rs | 108 +++- 21 files changed, 2085 insertions(+), 763 deletions(-) create mode 100644 src/app/action.rs create mode 100644 src/app/buffer.rs create mode 100644 src/app/calls.rs create mode 100644 src/app/commands.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/mode.rs create mode 100644 src/model/op.rs create mode 100644 src/view/canvas/view.rs diff --git a/Cargo.toml b/Cargo.toml index 3fa9e08..8485602 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,3 +37,8 @@ all = { level = "warn", priority = -1 } lto = true codegen-units = 1 strip = true + +# The canvas example is the build's checkpoint: `cargo test` runs its tests too. +[[example]] +name = "canvas" +test = true diff --git a/DESIGN.md b/DESIGN.md index 34dd047..ae80bf0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -392,36 +392,53 @@ cumulus/ └─ src/ ├─ main.rs command-line arguments, then app::run ├─ lib.rs the module list - ├─ model/ ids, text, geometry, look, node, portal, link, document, edit, history, fold + ├─ model/ ids, text, geometry, look, node, portal, link, document, edit, op (edits as + │ data), history, fold ├─ input/ key (vim notation), command (Command enum + names), bindings (default tables), - │ keymap (+ keys.toml), engine (counts, registers, operators), which_key, mouse + │ keymap (+ keys.toml), engine (counts, registers, operators), mouse ├─ command/ registry, parse, complete, history - ├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, render, hit, - │ navigate, placement), outline, note, board - ├─ ui/ text_editor (+ vim normal), which_key, command_line, status_bar, document_bar, - │ card, menu, picker, help, theme, glyphs + ├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render, + │ navigate, placement, view), outline, note, board + ├─ ui/ text_editor (+ vim normal), which_key, command_line, line_input, status_bar, + │ document_bar, card, menu, picker, help, theme, glyphs ├─ config/ paths, settings, keys_file, theme_file (TOML) ├─ storage/ document_file (RON), atomic save, backups, lock, state, watch (change polling) - └─ app/ App, mode, action, buffers, panes (split tree), jumps, marks, registers, - portals (cache, backlinks, renames), update, render, - terminal (setup, teardown, panic hook) + └─ app/ action, mode, buffer, commands (keys every view shares), calls (`:` commands); + later App, panes (split tree), jumps, registers, portals (cache, backlinks, + renames), update, render, terminal (setup, teardown, panic hook) ``` -**Flow:** a terminal event comes in → `input` turns the key or mouse event into -a `Command` → the focused view turns that command into `Action`s → -`App::update` applies them (the only place data changes) → the screen is drawn -from the state. +**Flow:** a terminal event comes in → the key engine turns keys into a typed +`Command` (with its count and register) → the focused view answers it with +`Action`s, and `app::commands::shared` answers the commands the view leaves +(undo, marks, adding, deleting, folds, modes…), so a view can give a key its own +meaning → a `:` line is parsed into a `Call` and `app::calls` answers it → +`update` carries out the actions in order, stopping at the first problem: +document actions go to the document's `Buffer` (the only place a document +changes: the edit, its undo step, the cursor and the marks), the rest change +the mode or the app → the screen is drawn from the state. + +Edits travel as data (`EditOp`), so views never touch the document. A `Group` +makes several edits one undo step: a whole move-mode session, a mouse drag. ```rust -trait View { // expected shape; stage 5 settles it from what stages 2–4 needed +trait View { // settled in stage 5 fn kind(&self) -> ViewKind; - fn keys(&self, mode: &Mode) -> &[KeyTable]; // its tables, most specific first - fn command(&mut self, command: Command, ctx: &Ctx) -> Vec; - fn mouse(&mut self, gesture: Gesture, ctx: &Ctx) -> Vec; - fn render(&self, frame: &mut Frame, area: Rect, ctx: &Ctx); // reads state, draws, changes nothing + fn contexts(&self, mode: &Mode) -> &'static [Context]; // its key tables, most specific first + fn layout(&mut self, area: Rect, ctx: &ViewContext); // fit to the area, follow the cursor + fn render(&self, frame: &mut Frame, ctx: &ViewContext); // draws the layout, changes nothing + fn command(&mut self, typed: &Typed, ctx: &ViewContext) -> Option>; // None: not mine + fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext) -> Vec; } +// ViewContext: the document, the cursor, the mode, the theme and the view settings. ``` +What changed from the first sketch: `layout` comes before `render`, because a +view keeps the frame it drew (the canvas matches clicks against it) and moves +its camera to follow the cursor, and drawing should change nothing; `command` +takes the whole `Typed` (counts matter) and returns `None` for commands it +leaves to the shared ones. + ## Build order Hardest parts first, so they shape a simple interface that every view shares diff --git a/README.md b/README.md index 901926e..d980677 100644 --- a/README.md +++ b/README.md @@ -60,4 +60,5 @@ While the app is being built, the examples try out each part: ```sh cargo run --example canvas # the canvas, vim keys, which-key, :commands cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text +cargo test --example canvas # type key sequences into it, and check what they do ``` diff --git a/examples/canvas.rs b/examples/canvas.rs index 39606b5..9691432 100644 --- a/examples/canvas.rs +++ b/examples/canvas.rs @@ -1,8 +1,12 @@ -//! A playground for the canvas (stages 2 to 4 of the build): a sample +//! A playground for the canvas (stages 2 to 5 of the build): a sample //! document to move around, zoom, fold and rearrange, with the real vim keys, //! the which-key popup and the `:` command line. Text editing and the other //! views come in later stages. //! +//! It runs the way the app will: the key engine turns keys into commands, +//! the canvas view (or the commands every view shares) turns those into +//! actions, and [`Playground::update`] carries them out. +//! //! Run it with `cargo run --example canvas`. A few keys to start with: //! //! | Keys | What they do | @@ -15,6 +19,7 @@ //! | `x` `dd` `dib` | delete the node / the branch / the children | //! | `M` | move mode: hjkl moves the branch, Enter ends | //! | `u` `Ctrl-r` | undo / redo | +//! | `ma` `'a` | mark a node / jump back to it (`''`: where you were) | //! | `` | the action menu (`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 | @@ -22,30 +27,27 @@ //! 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::app::action::{Action, AppAction, Message, Tone}; +use cumulus::app::buffer::Buffer; +use cumulus::app::mode::{Grab, Mode}; +use cumulus::app::{calls, commands}; use cumulus::command::history::History as CommandHistory; -use cumulus::command::parse::{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::command::parse::{SetArg, Sources, parse}; +use cumulus::input::command::{Bound, Command, help_for}; +use cumulus::input::engine::{KeyEngine, Outcome}; use cumulus::input::key::Key; use cumulus::input::keymap::Keymap; -use cumulus::input::mouse::{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::input::mouse::{Gesture, MouseReader}; +use cumulus::model::Document; use cumulus::storage::document_file; use cumulus::ui::command_line::{CommandLine, LineOutcome, PopupMode}; use cumulus::ui::theme::Theme; use cumulus::ui::which_key::WhichKey; -use cumulus::view::canvas::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 cumulus::view::canvas::CanvasView; +use cumulus::view::{View, ViewContext, ViewSettings}; use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Rect}; @@ -65,9 +67,6 @@ const KEYS_TOML: &str = r#" /// 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()?; @@ -84,85 +83,66 @@ fn main() -> io::Result<()> { 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, - }, +/// What the view looks at. +struct State { + buffer: Buffer, + mode: Mode, + theme: Theme, + settings: ViewSettings, } -/// 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 }, - /// Panning the view: the cell the mouse was last on. - Pan { last: ScreenPos }, +impl State { + fn ctx(&self) -> ViewContext<'_> { + ViewContext { + doc: self.buffer.doc(), + cursor: self.buffer.cursor(), + mode: &self.mode, + theme: &self.theme, + settings: &self.settings, + } + } } struct Playground { - doc: Document, - history: History, - cursor: NodeId, - camera: Camera, - options: SceneOptions, - theme: Theme, + /// The one view (splits come in stage 7). + view: CanvasView, + state: State, keymap: Keymap, keys: KeyEngine, - which_key: bool, - mode: Mode, mouse: MouseReader, - drag: Option, - /// The command line, while it's open. - command_line: Option, + which_key: bool, 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, - before_jump: Option, - /// 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, + /// The status bar's message, until the next key. + message: Option, + quit: bool, } 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(), + view: CanvasView::new(), + state: State { + buffer: Buffer::new(doc), + mode: Mode::Normal, + theme: Theme::default(), + settings: ViewSettings::default(), + }, keymap, keys: KeyEngine::default(), - which_key: true, - mode: Mode::Normal, mouse: MouseReader::default(), - drag: None, - command_line: None, + which_key: true, 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(" · "), + message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))), + quit: false, } } fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { - loop { + while !self.quit { terminal.draw(|frame| self.draw(frame))?; // While a sequence is half typed, wake up now and then: its // which-key popup may be due. @@ -174,10 +154,8 @@ impl Playground { 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(()); + if let Some(key) = Key::from_event(event) { + self.key(key); } } Event::Mouse(event) => { @@ -188,6 +166,159 @@ impl Playground { _ => {} } } + Ok(()) + } + + // ----- Input ---------------------------------------------------------- + + fn gesture(&mut self, gesture: Gesture) { + let actions = self.view.mouse(gesture, &self.state.ctx()); + self.update(actions); + } + + fn key(&mut self, key: Key) { + self.message = None; + if let Mode::Command(_) = self.state.mode { + return self.command_line_key(key); + } + let tables = self.keymap.tables(self.view.contexts(&self.state.mode)); + match self.keys.press(key, &tables) { + Outcome::Run(typed) => { + // The view has the first say; the shared commands get the rest. + let ctx = self.state.ctx(); + let actions = self.view.command(&typed, &ctx).or_else(|| commands::shared(&typed, &ctx)); + let actions = actions.unwrap_or_else(|| { + let later = + format!("\"{}\" comes in a later stage of the build", describe(typed.command)); + vec![Action::say(Message::info(later))] + }); + self.update(actions); + } + Outcome::Unknown(keys) => self.message = Some(Message::info(format!("{keys} does nothing here"))), + Outcome::Waiting | Outcome::Cancelled => {} + } + } + + /// A key while the command line is open. + fn command_line_key(&mut self, key: Key) { + let sources = self.sources(); + let Mode::Command(line) = &mut self.state.mode else { return }; + match line.key(key, &sources, &self.command_history) { + LineOutcome::Typing => {} + LineOutcome::Close => self.state.mode = Mode::Normal, + LineOutcome::Run(text) => { + self.state.mode = Mode::Normal; + if text.trim().is_empty() { + return; + } + self.command_history.add(&text); + let actions = match parse(&text, &sources) { + Ok(call) => calls::actions_for(call, &self.state.ctx()), + Err(error) => vec![Action::say(Message::problem(error.to_string()))], + }; + self.update(actions); + } + } + } + + /// What the command line can name: this document, its nodes, the + /// palette's colors, its tags. + fn sources(&self) -> Sources { + let doc = self.state.buffer.doc(); + let nodes = doc.outline().into_iter().filter_map(|id| Some((id, doc.node(id)?.title().to_string()))); + let mut tags: Vec = doc + .outline() + .into_iter() + .filter_map(|id| doc.node(id)) + .flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::>()) + .collect(); + tags.sort(); + tags.dedup(); + Sources { + documents: vec![doc.title().to_string()], + nodes: nodes.collect(), + colors: self.state.theme.palette.keys().map(ToString::to_string).collect(), + styles: self.state.theme.node_styles.keys().map(ToString::to_string).collect(), + themes: vec!["default".into()], + settings: vec!["completion_popup".into()], + tags, + folder: std::env::current_dir().unwrap_or_default(), + } + } + + // ----- Update --------------------------------------------------------- + + /// Carries out actions in order. A problem stops the rest, since they + /// may count on it. + fn update(&mut self, actions: Vec) { + for action in actions { + let message = match action { + Action::Doc(action) => self.state.buffer.apply(action), + Action::App(action) => self.app(action), + }; + if let Some(message) = message { + let problem = message.tone == Tone::Problem; + self.message = Some(message); + if problem { + break; + } + } + } + } + + fn app(&mut self, action: AppAction) -> Option { + match action { + AppAction::EnterMoveMode => self.state.mode = Mode::Move(Grab::new()), + AppAction::ToggleWhole => { + if let Mode::Move(grab) = &mut self.state.mode { + grab.whole = !grab.whole; + } + } + AppAction::LeaveMode => self.state.mode = Mode::Normal, + AppAction::OpenCommandLine => { + let line = CommandLine::new(&self.sources(), &self.command_history, self.popup); + self.state.mode = Mode::Command(line); + } + AppAction::ToggleFocus => { + let focus = &mut self.state.settings.focus; + *focus = !*focus; + let text = + if *focus { "focus on: branches the cursor isn't in fold away" } else { "focus off" }; + return Some(Message::info(text)); + } + AppAction::ToggleWhichKey => { + self.which_key = !self.which_key; + return Some(Message::info(if self.which_key { "key hints on" } else { "key hints off" })); + } + AppAction::Set(arg) => return Some(self.set(arg)), + AppAction::Quit => self.quit = true, + AppAction::Say(message) => return Some(message), + } + None + } + + /// `:set`. The playground has one setting; the app's come in stage 8. + fn set(&mut self, arg: SetArg) -> Message { + let value = match arg { + SetArg::Value(_, value) => value, + SetArg::Reset(_) => popup_name(PopupMode::default()).to_string(), + SetArg::Show(_) => { + return Message::info(format!("completion_popup = {}", popup_name(self.popup))); + } + SetArg::On(_) | SetArg::Off(_) | SetArg::Toggle(_) => { + return Message::problem("completion_popup takes a value: :set completion_popup=on-tab"); + } + }; + match PopupMode::named(&value) { + Some(popup) => { + self.popup = popup; + Message::done(format!("completion_popup = {value}")) + } + None => { + let values: Vec<&str> = PopupMode::VALUES.iter().map(|(name, _)| *name).collect(); + Message::problem(format!("completion_popup can be {}", values.join(" or "))) + } + } } // ----- Drawing -------------------------------------------------------- @@ -195,55 +326,47 @@ impl Playground { 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); + let ctx = self.state.ctx(); + self.view.layout(canvas, &ctx); + self.view.render(frame, &ctx); // While the command line is open, it takes the status line's place. - if let Some(line) = &self.command_line { - line.render(frame, status, canvas, &self.theme); + if let Mode::Command(line) = &self.state.mode { + line.render(frame, status, canvas, &self.state.theme); return; } if self.which_key_due() - && let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts())) + && let Some(hints) = self.keys.hints(&self.keymap.tables(self.view.contexts(&self.state.mode))) { - frame.render_widget(WhichKey { hints: &hints, theme: &self.theme }, canvas); + frame.render_widget(WhichKey { hints: &hints, theme: &self.state.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 · menu · q quit", - ), - Mode::Move { whole: true, .. } => { - (" MOVE ", "hjkl moves the branch (HJKL by 5) · M just this node · Enter done") + let hints = match self.state.mode { + Mode::Move(Grab { whole: true, .. }) => { + "hjkl moves the branch (HJKL by 5) · M just this node · Enter done" } - Mode::Move { whole: false, .. } => { - (" MOVE ", "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done") + Mode::Move(_) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done", + Mode::Normal | Mode::Command(_) => { + "hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · : command · menu · q quit" } }; - 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 doc = self.state.buffer.doc(); + let title = + doc.node(self.state.buffer.cursor()).map(|node| node.title().to_string()).unwrap_or_default(); + let focus = if self.state.settings.focus { " · focus" } else { "" }; + let middle = match &self.message { + None => hints.dark_gray(), + Some(Message { text, tone: Tone::Problem }) => text.clone().light_red(), + Some(Message { text, tone: Tone::Done }) => text.clone().green(), + Some(Message { text, tone: Tone::Info }) => text.clone().yellow(), + }; let line = Line::from(vec![ - mode.black().on_cyan().bold(), - format!(" {}{focus} │ {title} │ ", self.camera.zoom.name()).into(), + format!(" {} ", self.state.mode.label()).black().on_cyan().bold(), + format!(" {}{focus} │ {title} │ ", self.view.camera().zoom.name()).into(), middle, ]); frame.render_widget(Paragraph::new(line), area); @@ -259,597 +382,186 @@ impl Playground { }; 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 = doc - .outline() - .into_iter() - .filter_map(|id| doc.node(id)) - .flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::>()) - .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 = 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 = 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) { - 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) { - 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 = - 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"); - } +/// What a command does, in words: its own help, or its operator's. +fn describe(command: Command) -> &'static str { + match (help_for(Bound::Command(command)), command) { + ("", Command::Operate(operator, _)) => help_for(Bound::Operator(operator)), + (help, _) => help, } +} - 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 = 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 = 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)); - } +/// A completion popup setting's name, as `:set` takes it. +fn popup_name(popup: PopupMode) -> &'static str { + PopupMode::VALUES.iter().find(|(_, mode)| *mode == popup).map_or("?", |(name, _)| *name) +} - /// Runs an edit and records it for undo; shows the error if it's refused. - fn edit(&mut self, steps: impl FnOnce(&mut Edit<'_>) -> Result) -> Option { - match self.doc.edit(steps) { - Ok((value, change)) => { - self.history.record(change); - Some(value) - } - Err(error) => { - self.message = error.to_string(); - None - } - } +/// The playground driven by typed keys and mouse gestures on a test +/// terminal: what this stage says works, checked end to end. +#[cfg(test)] +mod tests { + use super::*; + use cumulus::input::key::parse_keys; + use cumulus::input::keymap::DEFAULT_LEADER; + use cumulus::input::mouse::Cell; + use cumulus::model::{CanvasPos, NodeId}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + struct Harness { + playground: Playground, + terminal: Terminal, + } + + impl Harness { + fn new() -> Harness { + let doc = document_file::parse(include_str!("garden.cum")).unwrap(); + let terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); + let mut harness = Harness { playground: Playground::new(doc), terminal }; + harness.draw(); + harness + } + + fn draw(&mut self) { + let Harness { playground, terminal } = self; + terminal.draw(|frame| playground.draw(frame)).unwrap(); + } + + /// Types keys written in vim notation, drawing after each one as the + /// event loop does. + fn type_keys(&mut self, keys: &str) { + for key in parse_keys(keys, DEFAULT_LEADER).unwrap() { + self.playground.key(key); + self.draw(); + } + } + + fn mouse(&mut self, gesture: Gesture) { + self.playground.gesture(gesture); + self.draw(); + } + + /// The title of the cursor's node. + fn cursor(&self) -> String { + let buffer = &self.playground.state.buffer; + buffer.doc().node(buffer.cursor()).unwrap().title().to_string() + } + + fn message(&self) -> &str { + self.playground.message.as_ref().map_or("", |message| &message.text) + } + + /// The node with this title. + fn node(&self, title: &str) -> NodeId { + let doc = self.playground.state.buffer.doc(); + doc.outline().into_iter().find(|&id| doc.node(id).unwrap().title().as_str() == title).unwrap() + } + + fn pos(&self, title: &str) -> CanvasPos { + self.playground.state.buffer.doc().node(self.node(title)).unwrap().pos + } + + /// What's on screen, row by row. + fn screen(&self) -> String { + let buffer = self.terminal.backend().buffer(); + let rows = buffer.content().chunks(usize::from(buffer.area.width)); + rows.map(|row| row.iter().map(|cell| cell.symbol()).collect::() + "\n").collect() + } + } + + #[test] + fn marks_counts_and_undo() { + let mut h = Harness::new(); + h.type_keys("glma"); + assert_eq!(h.cursor(), "Soil"); + assert_eq!(h.message(), "mark a is on \"Soil\": 'a comes back here"); + h.type_keys("gg'a"); + assert_eq!(h.cursor(), "Soil"); + h.type_keys("''"); + assert_eq!(h.cursor(), "Garden plans", "'' goes back to before the last jump"); + h.type_keys("'a2dd"); + assert_eq!(h.message(), "deleted 4 nodes · u to undo", "Soil, and Raised beds with its two"); + assert_eq!(h.cursor(), "Watering"); + assert!(!h.screen().contains("Raised beds")); + h.type_keys("u"); + assert_eq!(h.cursor(), "Soil"); + assert!(h.screen().contains("Raised beds")); + } + + #[test] + fn move_mode_moves_the_branch_as_one_undo_step() { + let mut h = Harness::new(); + h.type_keys("glgjM"); + assert_eq!(h.cursor(), "Raised beds"); + assert_eq!(h.playground.state.mode.label(), "MOVE"); + h.type_keys("3lJ"); + assert_eq!(h.playground.state.mode.label(), "NORMAL"); + assert_eq!(h.pos("Raised beds"), CanvasPos::new(25, 12), "3 right, 5 down"); + assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(46, 10), "its branch came along"); + h.type_keys("u"); + assert_eq!(h.pos("Raised beds"), CanvasPos::new(22, 7)); + assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(43, 5)); + } + + #[test] + fn dragging_a_node_moves_its_branch() { + let mut h = Harness::new(); + let soil = h.playground.view.camera().to_screen(h.pos("Soil")); + let at = + |col: i32, row: i32| Cell { col: u16::try_from(col).unwrap(), row: u16::try_from(row).unwrap() }; + let (start, end) = (at(soil.col + 1, soil.row + 1), at(soil.col + 5, soil.row + 3)); + h.mouse(Gesture::Press { at: start, ctrl: false }); + assert_eq!(h.cursor(), "Soil", "the press selects"); + h.mouse(Gesture::Drag { from: start, at: end, ctrl: false }); + h.mouse(Gesture::Release { at: end, dragged: true }); + assert_eq!(h.pos("Soil"), CanvasPos::new(26, 4)); + h.type_keys("u"); + assert_eq!(h.pos("Soil"), CanvasPos::new(22, 2)); + } + + #[test] + fn command_lines_run_through_the_same_actions() { + let mut h = Harness::new(); + h.type_keys(":color teal"); + let root = h.playground.state.buffer.doc().node(h.node("Garden plans")).unwrap(); + assert_eq!(root.look.color.as_ref().map(|color| color.as_str()), Some("teal")); + h.type_keys(":in mulch"); + assert_eq!(h.message(), "captured \"mulch\" to the Inbox"); + assert_eq!(h.playground.state.buffer.doc().parent(h.node("mulch")), Some(h.node("Inbox"))); + assert_eq!(h.cursor(), "Garden plans", "capturing leaves the cursor where it was"); + h.type_keys(":find drip"); + assert_eq!(h.cursor(), "Drip line"); + assert_eq!(h.message(), "\"drip\": match 1 of 1"); + h.type_keys(":set completion_popup=on-tab"); + assert_eq!(h.playground.popup, PopupMode::OnTab); + h.type_keys(":frobnicate"); + assert_eq!(h.playground.message.as_ref().map(|message| message.tone), Some(Tone::Problem)); + } + + #[test] + fn adding_folding_zooming_and_quitting() { + let mut h = Harness::new(); + h.type_keys("a"); + assert_eq!(h.cursor(), "new idea"); + h.type_keys("u"); + assert_eq!(h.cursor(), "Garden plans", "undo goes back to where the change was"); + h.type_keys("glgjza"); + assert_eq!(h.cursor(), "Raised beds"); + assert!(h.screen().contains("+2"), "a folded branch shows how many nodes it hides"); + assert!(!h.screen().contains("40 cm high")); + h.type_keys("-"); + assert!(h.screen().lines().last().unwrap().contains("Far"), "the status bar shows the zoom"); + h.type_keys("q"); + assert!(h.playground.quit); + } + + #[test] + fn keys_from_later_stages_say_so() { + let mut h = Harness::new(); + h.type_keys("yy"); + assert_eq!(h.message(), "\"yank (copy)…\" comes in a later stage of the build"); + h.type_keys("i"); + assert_eq!(h.message(), "\"edit the title\" comes in a later stage of the build"); } } diff --git a/src/app/action.rs b/src/app/action.rs new file mode 100644 index 0000000..e926568 --- /dev/null +++ b/src/app/action.rs @@ -0,0 +1,133 @@ +//! Actions: what views, keys and the command line ask for. Views never +//! change the document, the cursor or the mode themselves. They answer with +//! actions, and the app carries them out: [`Buffer::apply`] for a document +//! and its cursor, the app for the rest. +//! +//! [`Buffer::apply`]: super::buffer::Buffer::apply + +use crate::command::parse::SetArg; +use crate::model::{EditOp, Group, NodeId}; + +/// How a status-bar message is meant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tone { + /// Something to know. + Info, + /// Something got done. + Done, + /// Something didn't work. + Problem, +} + +/// A message for the status bar. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub text: String, + pub tone: Tone, +} + +impl Message { + pub fn info(text: impl Into) -> Message { + Message { text: text.into(), tone: Tone::Info } + } + + pub fn done(text: impl Into) -> Message { + Message { text: text.into(), tone: Tone::Done } + } + + pub fn problem(text: impl Into) -> Message { + Message { text: text.into(), tone: Tone::Problem } + } +} + +/// Where the cursor goes after an edit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CursorAfter { + /// Where it is (or to the root, if its node is gone). + Stay, + /// To the node the edit added. + Added, + /// To this node. + To(NodeId), +} + +/// What happens to a document and its cursor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DocAction { + /// Move the cursor. A `jump` is one that `''` can come back from. + MoveCursor { + to: NodeId, + jump: bool, + }, + /// Change the document, as one undo step (or as part of its group's). + /// The op is boxed because it can hold whole nodes, which would make + /// every action that size. + Edit { + op: Box, + cursor: CursorAfter, + group: Option, + }, + Undo(u32), + Redo(u32), + SetMark(char), + /// Go to a mark; `'` and `` ` `` go back to before the last jump. + JumpToMark(char), +} + +/// What happens around the documents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppAction { + /// Move mode: hjkl moves the cursor's branch. + EnterMoveMode, + /// In move mode: the whole branch, or just the node. + ToggleWhole, + /// Back to normal mode. + LeaveMode, + OpenCommandLine, + /// Focus mode: Open branches act like Auto ones. + ToggleFocus, + ToggleWhichKey, + /// Change a setting. + Set(SetArg), + Quit, + Say(Message), +} + +/// Anything views, keys and the command line ask for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Doc(DocAction), + App(AppAction), +} + +impl Action { + /// Move the cursor to a node. + pub fn cursor(to: NodeId) -> Action { + Action::Doc(DocAction::MoveCursor { to, jump: false }) + } + + /// Jump to a node: `''` comes back. + pub fn jump(to: NodeId) -> Action { + Action::Doc(DocAction::MoveCursor { to, jump: true }) + } + + /// Change the document, as one undo step; the cursor stays. + pub fn edit(op: EditOp) -> Action { + Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group: None }) + } + + /// Change the document, as one undo step, then move the cursor. + pub fn edit_then(op: EditOp, cursor: CursorAfter) -> Action { + Action::Doc(DocAction::Edit { op: Box::new(op), cursor, group: None }) + } + + /// Change the document as part of a group's undo step. + pub fn edit_in(op: EditOp, group: Group) -> Action { + Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group: Some(group) }) + } + + /// Show a message. + pub fn say(message: Message) -> Action { + Action::App(AppAction::Say(message)) + } +} diff --git a/src/app/buffer.rs b/src/app/buffer.rs new file mode 100644 index 0000000..d7e50bc --- /dev/null +++ b/src/app/buffer.rs @@ -0,0 +1,190 @@ +//! An open document: the document, its undo history, its cursor and its +//! marks. [`Buffer::apply`] is the one place where a document changes. + +use std::collections::BTreeMap; + +use super::action::{CursorAfter, DocAction, Message}; +use crate::model::{Applied, Document, History, NodeId}; + +/// An open document, with what goes with it while it's open. +#[derive(Debug)] +pub struct Buffer { + doc: Document, + history: History, + cursor: NodeId, + marks: BTreeMap, + /// Where the cursor was before the last jump, for `''`. + before_jump: Option, +} + +impl Buffer { + /// A buffer for a document, with the cursor on its root. + pub fn new(doc: Document) -> Buffer { + Buffer { + cursor: doc.root(), + doc, + history: History::default(), + marks: BTreeMap::new(), + before_jump: None, + } + } + + pub fn doc(&self) -> &Document { + &self.doc + } + + /// The node the cursor is on (always one that exists). + pub fn cursor(&self) -> NodeId { + self.cursor + } + + pub fn history(&self) -> &History { + &self.history + } + + /// Carries out an action on the document or its cursor, and says how it + /// went when there's something to say. + pub fn apply(&mut self, action: DocAction) -> Option { + match action { + DocAction::MoveCursor { to, jump } => self.move_cursor(to, jump), + DocAction::Edit { op, cursor, group } => { + let (applied, change) = match self.doc.apply(&op) { + Ok(done) => done, + Err(error) => return Some(Message::problem(error.to_string())), + }; + match group { + Some(group) => self.history.record_in(change, group), + None => self.history.record(change), + } + self.cursor = match (cursor, applied) { + (CursorAfter::Added, Applied::Added(id)) | (CursorAfter::To(id), _) => id, + _ => self.cursor, + }; + self.keep_cursor(); + match applied { + Applied::Removed(count) => { + Some(Message::done(format!("deleted {} · u to undo", nodes(count)))) + } + Applied::Added(_) | Applied::Changed => None, + } + } + DocAction::Undo(times) => self.undo_or_redo(times, true), + DocAction::Redo(times) => self.undo_or_redo(times, false), + DocAction::SetMark(name) => { + self.marks.insert(name, self.cursor); + let title = + self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default(); + Some(Message::done(format!("mark {name} is on \"{title}\": '{name} comes back here"))) + } + DocAction::JumpToMark('\'' | '`') => match self.before_jump { + Some(id) if self.doc.contains(id) => self.move_cursor(id, true), + _ => Some(Message::info("no jump to go back from yet")), + }, + DocAction::JumpToMark(name) => match self.marks.get(&name) { + Some(&id) if self.doc.contains(id) => self.move_cursor(id, true), + Some(_) => Some(Message::problem(format!("the node marked {name} is gone"))), + None => Some(Message::info(format!("no mark {name} yet: m{name} sets it"))), + }, + } + } + + fn move_cursor(&mut self, to: NodeId, jump: bool) -> Option { + if !self.doc.contains(to) { + return Some(Message::problem("that node is gone")); + } + if jump && to != self.cursor { + self.before_jump = Some(self.cursor); + } + self.cursor = to; + None + } + + /// Undoes (or redoes) up to `times` changes; the cursor goes where the + /// last one happened. + fn undo_or_redo(&mut self, times: u32, undo: bool) -> Option { + let mut landed = None; + for _ in 0..times { + let change = + if undo { self.history.undo(&mut self.doc) } else { self.history.redo(&mut self.doc) }; + let Some(change) = change else { break }; + landed = Some(change.nodes().find(|id| self.doc.contains(*id))); + } + match landed { + None => Some(Message::info(if undo { "nothing to undo" } else { "nothing to redo" })), + Some(target) => { + self.cursor = target.unwrap_or(self.cursor); + self.keep_cursor(); + None + } + } + } + + /// The cursor's node may be gone after an edit or an undo: then it goes + /// to the root. + fn keep_cursor(&mut self) { + if !self.doc.contains(self.cursor) { + self.cursor = self.doc.root(); + } + } +} + +/// "1 node", "3 nodes". +fn nodes(count: usize) -> String { + if count == 1 { "1 node".to_string() } else { format!("{count} nodes") } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::{garden, idea}; + use crate::model::{EditOp, Spot}; + + fn edit(op: EditOp, cursor: CursorAfter) -> DocAction { + DocAction::Edit { op: Box::new(op), cursor, group: None } + } + + #[test] + fn edits_move_the_cursor_and_undo_brings_it_back() { + let (doc, g) = garden(); + let mut buffer = Buffer::new(doc); + buffer.apply(edit( + EditOp::Add { node: idea("new"), spot: Spot::LastChildOf(g.soil) }, + CursorAfter::Added, + )); + let added = buffer.cursor(); + assert_eq!(buffer.doc().parent(added), Some(g.soil)); + let message = buffer.apply(edit(EditOp::RemoveBranches(vec![g.soil]), CursorAfter::To(g.beds))); + assert_eq!(message, Some(Message::done("deleted 2 nodes · u to undo"))); + assert_eq!(buffer.cursor(), g.beds); + buffer.apply(DocAction::Undo(1)); + assert_eq!(buffer.cursor(), g.soil, "back where the change was"); + assert_eq!(buffer.apply(DocAction::Undo(5)), None); + assert_eq!(buffer.apply(DocAction::Undo(1)), Some(Message::info("nothing to undo"))); + } + + #[test] + fn marks_and_jumping_back() { + let (doc, g) = garden(); + let mut buffer = Buffer::new(doc); + buffer.apply(DocAction::MoveCursor { to: g.cedar, jump: false }); + buffer.apply(DocAction::SetMark('a')); + buffer.apply(DocAction::MoveCursor { to: g.compost, jump: true }); + buffer.apply(DocAction::JumpToMark('a')); + assert_eq!(buffer.cursor(), g.cedar); + buffer.apply(DocAction::JumpToMark('\'')); + assert_eq!(buffer.cursor(), g.compost, "'' goes back to before the jump"); + assert_eq!( + buffer.apply(DocAction::JumpToMark('z')), + Some(Message::info("no mark z yet: mz sets it")) + ); + } + + #[test] + fn the_cursor_never_rests_on_a_deleted_node() { + let (doc, g) = garden(); + let mut buffer = Buffer::new(doc); + buffer.apply(DocAction::MoveCursor { to: g.cedar, jump: false }); + buffer.apply(edit(EditOp::RemoveBranches(vec![g.beds]), CursorAfter::Stay)); + assert_eq!(buffer.cursor(), buffer.doc().root()); + } +} diff --git a/src/app/calls.rs b/src/app/calls.rs new file mode 100644 index 0000000..130b60f --- /dev/null +++ b/src/app/calls.rs @@ -0,0 +1,198 @@ +//! What `:` commands do: [`actions_for`] turns a parsed [`Call`] into +//! actions, the way views turn keys into actions. + +use super::action::{Action, AppAction, CursorAfter, Message}; +use super::commands::set_fold; +use crate::command::parse::{Call, TagChange}; +use crate::model::{CanvasPos, CanvasRect, EditOp, Inbox, Link, LinkKey, Node, NodeId, Title}; +use crate::view::ViewContext; +use crate::view::canvas::placement::{child_spot, first_child_spot, free_spot}; + +/// The title of the loose node `:in` collects ideas under. +const INBOX: &str = "Inbox"; + +/// The actions a command line asks for. Commands from later stages of the +/// build say so. +pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec { + let id = ctx.cursor; + match call { + Call::Quit { .. } | Call::QuitAll { .. } | Call::WriteQuit | Call::ClosePane => { + vec![Action::App(AppAction::Quit)] + } + Call::Fold(fold) => set_fold(ctx, fold), + Call::Focus => vec![Action::App(AppAction::ToggleFocus)], + Call::Color(color) => vec![Action::edit(EditOp::SetColor { id, color })], + Call::Border(border) => vec![Action::edit(EditOp::SetBorder { id, border: Some(border) })], + Call::Tags(changes) => { + let (mut add, mut remove) = (Vec::new(), Vec::new()); + for change in changes { + match change { + TagChange::Add(tag) => add.push(tag), + TagChange::Remove(tag) => remove.push(tag), + } + } + vec![Action::edit(EditOp::Tag { id, add, remove })] + } + // A document's name is its root's title. + Call::Rename(title) => vec![Action::edit(EditOp::SetTitle { id: ctx.doc.root(), title })], + Call::Capture(text) => capture(ctx, &text), + Call::Link(target) => match Link::new(id, target) { + Some(link) => vec![Action::edit(EditOp::Link(link))], + None => say(Message::problem("a node can't link to itself")), + }, + Call::Unlink(target) => unlink(ctx, target), + Call::Find(text) => find(ctx, &text), + Call::Set(arg) => vec![Action::App(AppAction::Set(arg))], + Call::Version => say(Message::info(crate::version())), + Call::Help(_) => later("the help screen comes in stage 7; Tab completes any command"), + Call::Split(_) | Call::VSplit(_) | Call::Only | Call::Show(_) => { + later("splits and the other views come in stage 7") + } + Call::Edit { .. } + | Call::New(_) + | Call::Write + | Call::WriteAll + | Call::Buffers + | Call::Buffer(_) + | Call::CloseDocument { .. } + | Call::Portal(_) + | Call::Backlinks => later("documents, files and portals come in stage 8"), + Call::Jumps | Call::Marks | Call::Registers => { + later("the lists of jumps, marks and registers come in stage 8") + } + Call::Style(_) | Call::Theme(_) | Call::Config => later("theme and config files come in stage 8"), + Call::Board(_) | Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => { + later("this one comes in v1.1") + } + } +} + +fn say(message: Message) -> Vec { + vec![Action::say(message)] +} + +fn later(text: &str) -> Vec { + say(Message::info(text)) +} + +/// `:in`: adds an idea under the Inbox, a loose node made the first time. +/// The cursor stays where it is: capturing shouldn't break your flow. +fn capture(ctx: &ViewContext<'_>, text: &str) -> Vec { + let doc = ctx.doc; + let title = match Title::parse(text) { + Ok(title) => title, + Err(error) => return say(Message::problem(error.to_string())), + }; + let idea = Node::idea(title, CanvasPos::default()); + let (size, rects) = (ctx.box_size(&idea), ctx.box_rects()); + let existing = doc + .loose() + .iter() + .copied() + .find(|&id| doc.node(id).is_some_and(|node| node.title().as_str() == INBOX)); + let (inbox, pos) = match existing { + Some(inbox) => (Inbox::Existing(inbox), child_spot(doc, inbox, size, &rects)), + None => { + let Ok(title) = Title::parse(INBOX) else { return Vec::new() }; + let inbox = Node::idea(title, CanvasPos::default()); + let inbox_size = ctx.box_size(&inbox); + // Below everything else, at the left. + let below = rects.values().map(|rect| rect.bottom()).max().unwrap_or(0) + 2; + let inbox_pos = free_spot(CanvasPos::new(0, below), inbox_size, &rects); + let pos = first_child_spot(CanvasRect::new(inbox_pos, inbox_size), size, &rects); + (Inbox::New(Node { pos: inbox_pos, ..inbox }), pos) + } + }; + vec![ + Action::edit_then(EditOp::Capture { idea: Node { pos, ..idea }, inbox }, CursorAfter::Stay), + Action::say(Message::done(format!("captured \"{text}\" to the Inbox"))), + ] +} + +/// `:unlink`: the link to a node, or every link of the cursor's node. +fn unlink(ctx: &ViewContext<'_>, target: Option) -> Vec { + let keys: Vec = match target { + Some(target) => LinkKey::new(ctx.cursor, target).into_iter().collect(), + None => ctx.doc.links_of(ctx.cursor).map(Link::key).collect(), + }; + if keys.is_empty() { + return say(Message::info("this node has no links to remove")); + } + vec![Action::edit(EditOp::Unlink(keys))] +} + +/// `:find`: jumps to the next node whose title has the text in it, in +/// reading order, starting over at the top after the last. +fn find(ctx: &ViewContext<'_>, text: &str) -> Vec { + let doc = ctx.doc; + let wanted = text.to_lowercase(); + let order = doc.outline(); + let matches: Vec = (0..order.len()) + .filter(|&index| { + doc.node(order[index]).is_some_and(|node| node.title().as_str().to_lowercase().contains(&wanted)) + }) + .collect(); + let here = order.iter().position(|&id| id == ctx.cursor).unwrap_or(0); + let Some(nth) = matches.iter().position(|&index| index > here).or((!matches.is_empty()).then_some(0)) + else { + return say(Message::problem(format!("nothing matches \"{text}\""))); + }; + vec![ + Action::jump(order[matches[nth]]), + Action::say(Message::info(format!("\"{text}\": match {} of {}", nth + 1, matches.len()))), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::buffer::Buffer; + use crate::model::test_support::garden; + use crate::view::test_support::Setup; + + /// Runs a command line's actions on a buffer, as the app does. + fn run(buffer: &mut Buffer, setup: &Setup, call: Call) { + for action in actions_for(call, &setup.ctx(buffer.doc(), buffer.cursor())) { + if let Action::Doc(action) = action { + buffer.apply(action); + } + } + } + + #[test] + fn capture_fills_one_inbox_without_overlaps() { + let ((doc, g), setup) = (garden(), Setup::default()); + let mut buffer = Buffer::new(doc); + run(&mut buffer, &setup, Call::Capture("mulch".into())); + run(&mut buffer, &setup, Call::Capture("worms".into())); + let doc = buffer.doc(); + let inboxes: Vec = doc + .loose() + .iter() + .copied() + .filter(|&id| doc.node(id).unwrap().title().as_str() == INBOX) + .collect(); + assert_eq!(inboxes.len(), 1); + assert_eq!(doc.children(inboxes[0]).len(), 2); + assert_eq!(buffer.cursor(), g.root, "the cursor stays"); + let rects = setup.ctx(doc, g.root).box_rects(); + for (a, rect) in &rects { + assert!(rects.iter().all(|(b, other)| a == b || !rect.overlaps(*other)), "{a:?} overlaps"); + } + } + + #[test] + fn find_goes_round_the_matches() { + let ((doc, g), setup) = (garden(), Setup::default()); + let mut buffer = Buffer::new(doc); + // "Raised beds" and "Cedar" both have "ed" in them. + run(&mut buffer, &setup, Call::Find("ED".into())); + assert_eq!(buffer.cursor(), g.beds); + run(&mut buffer, &setup, Call::Find("ed".into())); + assert_eq!(buffer.cursor(), g.cedar); + run(&mut buffer, &setup, Call::Find("ed".into())); + assert_eq!(buffer.cursor(), g.beds, "back to the first"); + let nothing = actions_for(Call::Find("kale".into()), &setup.ctx(buffer.doc(), buffer.cursor())); + assert_eq!(nothing, say(Message::problem("nothing matches \"kale\""))); + } +} diff --git a/src/app/commands.rs b/src/app/commands.rs new file mode 100644 index 0000000..b30a7fa --- /dev/null +++ b/src/app/commands.rs @@ -0,0 +1,230 @@ +//! Key commands that mean the same in every view: undo, marks, modes, +//! adding and deleting nodes, folds, moving through the tree… +//! +//! The focused view gets each command first, so a view can give a key its +//! own meaning (in the Note view, `dd` will delete a line of text). +//! [`shared`] answers the ones it leaves. + +use super::action::{Action, AppAction, CursorAfter, DocAction, Message}; +use crate::input::command::{Command, Motion, Operator, Target}; +use crate::input::engine::Typed; +use crate::model::{CanvasPos, EditOp, Fold, Node, NodeId, Spot, Title, Visible}; +use crate::view::ViewContext; +use crate::view::canvas::placement::{below_spot, child_spot}; + +/// The actions for a command every view shares, or `None` when it isn't +/// one. +pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option> { + let times = typed.times(); + let cursor = ctx.cursor; + let actions = match typed.command { + Command::Move(motion) => return tree_motion(motion, times, ctx), + Command::Undo => vec![Action::Doc(DocAction::Undo(times))], + Command::Redo => vec![Action::Doc(DocAction::Redo(times))], + Command::SetMark(name) => vec![Action::Doc(DocAction::SetMark(name))], + Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))], + Command::AddChild => add(ctx, Spot::LastChildOf(cursor)), + Command::AddBelow => add(ctx, Spot::After(cursor)), + Command::AddAbove => add(ctx, Spot::Before(cursor)), + Command::DeleteNode => delete_node(ctx), + Command::Operate(Operator::Delete, Target::Current | Target::Branch) => delete_branches(ctx, times), + Command::Operate(Operator::Delete, Target::Children) => delete_children(ctx), + Command::ToggleFold => toggle_fold(ctx), + Command::OpenFold => set_fold(ctx, Fold::Open), + Command::CloseFold => set_fold(ctx, Fold::Closed), + Command::AutoFold => set_fold(ctx, Fold::Auto), + Command::OpenAllFolds => fold_all(ctx, Fold::Open), + Command::CloseAllFolds => fold_all(ctx, Fold::Closed), + Command::MoveMode => app(AppAction::EnterMoveMode), + Command::CommandLine => app(AppAction::OpenCommandLine), + Command::ToggleFocus => app(AppAction::ToggleFocus), + Command::ToggleWhichKey => app(AppAction::ToggleWhichKey), + Command::Close => app(AppAction::Quit), + // Esc always gets back to normal mode. + Command::Escape => app(AppAction::LeaveMode), + _ => return None, + }; + Some(actions) +} + +/// Sets how the cursor's node folds (`zo`, `zc`, `zf`, `:fold`). +pub fn set_fold(ctx: &ViewContext<'_>, fold: Fold) -> Vec { + if ctx.doc.children(ctx.cursor).is_empty() { + return say("nothing to fold: this node has no children"); + } + let mut actions = vec![Action::edit(EditOp::SetFold { ids: vec![ctx.cursor], fold })]; + // Folding Auto changes nothing on screen until the cursor leaves. + if fold == Fold::Auto { + actions.extend(say("this branch folds away when the cursor leaves it")); + } + actions +} + +fn app(action: AppAction) -> Vec { + vec![Action::App(action)] +} + +fn say(text: &str) -> Vec { + vec![Action::say(Message::info(text))] +} + +/// Moves through the tree: `gh` `gl` `gj` `gk`, `w` `b`, `gg` `G`. A count +/// repeats the move, each step starting where the last one landed (folds +/// included: an Auto fold opens once the cursor is inside). +fn tree_motion(motion: Motion, times: u32, ctx: &ViewContext<'_>) -> Option> { + use Motion as M; + let nowhere = match motion { + M::Parent => "this node is at the top", + M::FirstChild => "this node has no children", + M::NextSibling => "no sibling after this one", + M::PreviousSibling => "no sibling before this one", + M::Next => "this is the last node", + M::Previous => "this is the first node", + // gg and G land in the same place however often they're repeated. + M::First | M::Last => "", + // hjkl are the view's, and the rest come in later stages. + _ => return None, + }; + let jump = matches!(motion, M::First | M::Last); + let times = if jump { 1 } else { times }; + let mut at = ctx.cursor; + for _ in 0..times { + match tree_step(ctx, at, motion) { + Some(next) => at = next, + None => break, + } + } + let actions = if at == ctx.cursor { + if jump { Vec::new() } else { say(nowhere) } + } else if jump { + // gg and G are jumps, as in vim: '' comes back from them. + vec![Action::jump(at)] + } else { + vec![Action::cursor(at)] + }; + Some(actions) +} + +/// Where one step of a tree motion goes from `from`. +fn tree_step(ctx: &ViewContext<'_>, from: NodeId, motion: Motion) -> Option { + let doc = ctx.doc; + let shown = || Visible::new(doc, from, ctx.settings.focus).order; + match motion { + Motion::Parent => doc.parent(from), + Motion::FirstChild => doc.children(from).first().copied(), + Motion::NextSibling => doc.next_sibling(from), + Motion::PreviousSibling => doc.previous_sibling(from), + Motion::First => Some(doc.root()), + Motion::Last => shown().last().copied(), + Motion::Next | Motion::Previous => { + let order = shown(); + let here = order.iter().position(|&id| id == from)?; + let there = if motion == Motion::Next { here.checked_add(1) } else { here.checked_sub(1) }; + there.and_then(|index| order.get(index).copied()) + } + _ => None, + } +} + +/// Adds a "new idea" node at `spot`, placed next to its parent or sibling, +/// and moves the cursor to it. +fn add(ctx: &ViewContext<'_>, spot: Spot) -> Vec { + let Ok(title) = Title::parse("new idea") else { return Vec::new() }; + let node = Node::idea(title, CanvasPos::default()); + let (size, rects) = (ctx.box_size(&node), ctx.box_rects()); + let pos = match spot { + Spot::LastChildOf(parent) => child_spot(ctx.doc, parent, size, &rects), + _ => below_spot(ctx.cursor, size, &rects), + }; + vec![Action::edit_then(EditOp::Add { node: Node { pos, ..node }, spot }, CursorAfter::Added)] +} + +/// `x`: deletes just the cursor's node; its children take its place. +fn delete_node(ctx: &ViewContext<'_>) -> Vec { + let doc = ctx.doc; + let next = doc.children(ctx.cursor).first().copied().or_else(|| doc.parent(ctx.cursor)); + vec![Action::edit_then(EditOp::RemoveKeepingChildren(ctx.cursor), after(next))] +} + +/// `dd` or `dab`: the cursor's node and its branch. With a count, the next +/// siblings and their branches go too, as `3dd` deletes three lines in vim. +fn delete_branches(ctx: &ViewContext<'_>, count: u32) -> Vec { + let doc = ctx.doc; + let branches = doc.siblings_from(ctx.cursor, usize::try_from(count).unwrap_or(1)); + let Some(&last) = branches.last() else { return Vec::new() }; + // The cursor goes to the sibling after them, or before them, or up. + let next = doc + .next_sibling(last) + .or_else(|| doc.previous_sibling(ctx.cursor)) + .or_else(|| doc.parent(ctx.cursor)); + vec![Action::edit_then(EditOp::RemoveBranches(branches), after(next))] +} + +/// `dib`: everything below the cursor's node. +fn delete_children(ctx: &ViewContext<'_>) -> Vec { + let children = ctx.doc.children(ctx.cursor).to_vec(); + if children.is_empty() { + return say("this node has no children to delete"); + } + vec![Action::edit(EditOp::RemoveBranches(children))] +} + +/// Where the cursor goes after a delete: to `next`, or the root when there's +/// nowhere else. +fn after(next: Option) -> CursorAfter { + next.map_or(CursorAfter::Stay, CursorAfter::To) +} + +/// `za`: a closed branch opens; any other closes. +fn toggle_fold(ctx: &ViewContext<'_>) -> Vec { + let Some(node) = ctx.doc.node(ctx.cursor) else { return Vec::new() }; + set_fold(ctx, if node.fold == Fold::Closed { Fold::Open } else { Fold::Closed }) +} + +/// `zR` and `zM`: every branch in the document. +fn fold_all(ctx: &ViewContext<'_>, fold: Fold) -> Vec { + let doc = ctx.doc; + let ids = doc.outline().into_iter().filter(|&id| !doc.children(id).is_empty()).collect(); + vec![Action::edit(EditOp::SetFold { ids, fold })] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::garden; + use crate::view::test_support::Setup; + + fn typed(command: Command, count: Option) -> Typed { + Typed { command, count, register: None } + } + + #[test] + fn a_count_deletes_the_next_siblings_too() { + let ((doc, g), setup) = (garden(), Setup::default()); + let dd = Command::Operate(Operator::Delete, Target::Current); + assert_eq!( + shared(&typed(dd, Some(2)), &setup.ctx(&doc, g.soil)), + Some(vec![Action::edit_then( + EditOp::RemoveBranches(vec![g.soil, g.beds]), + CursorAfter::To(g.watering) + )]) + ); + } + + #[test] + fn tree_motions_repeat_and_say_when_they_cant() { + let ((doc, g), setup) = (garden(), Setup::default()); + let ctx = setup.ctx(&doc, g.soil); + let next_sibling = Command::Move(Motion::NextSibling); + assert_eq!(shared(&typed(next_sibling, Some(5)), &ctx), Some(vec![Action::cursor(g.watering)])); + assert_eq!( + shared(&typed(Command::Move(Motion::FirstChild), None), &ctx), + Some(say("this node has no children")) + ); + assert_eq!( + shared(&typed(Command::Move(Motion::First), None), &ctx), + Some(vec![Action::jump(g.root)]) + ); + assert_eq!(shared(&typed(Command::Move(Motion::Left), None), &ctx), None, "hjkl are the view's"); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..8390699 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,18 @@ +//! The app: everything around the views. +//! +//! How input becomes a change: +//! 1. A key goes through the key engine and comes out as a command; the +//! focused view answers it with [`action`]s, and [`commands::shared`] +//! answers what the view leaves. A `:` command line is answered by +//! [`calls::actions_for`] instead. +//! 2. Document actions go to the [`buffer`] holding the document: the one +//! place a document changes. The rest change the [`mode`] or the app. +//! +//! So far this is what stages 2 to 4 needed; stage 8 adds the main loop, +//! panes, files and settings. + +pub mod action; +pub mod buffer; +pub mod calls; +pub mod commands; +pub mod mode; diff --git a/src/app/mode.rs b/src/app/mode.rs new file mode 100644 index 0000000..4c6c9f1 --- /dev/null +++ b/src/app/mode.rs @@ -0,0 +1,51 @@ +//! Modes, as in vim: what keys mean depends on the mode. Each mode holds +//! what it needs, so a mode's state can't outlive it (there's no "move mode +//! flag" to forget to clear). + +use crate::model::Group; +use crate::ui::command_line::CommandLine; + +/// The app's mode. +#[derive(Debug, Clone, Default)] +pub enum Mode { + /// Moving around and acting on nodes. + #[default] + Normal, + /// Moving the cursor's node (or its branch) with hjkl. + Move(Grab), + /// Typing a `:` command. + Command(CommandLine), +} + +/// Move mode's state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Grab { + /// The whole branch moves, or just the node. + pub whole: bool, + /// Makes every move one undo step. + pub group: Group, +} + +impl Grab { + /// Moving a whole branch, as move mode starts. + pub fn new() -> Grab { + Grab { whole: true, group: Group::new() } + } +} + +impl Default for Grab { + fn default() -> Grab { + Grab::new() + } +} + +impl Mode { + /// The mode's name for the status bar. + pub fn label(&self) -> &'static str { + match self { + Mode::Normal => "NORMAL", + Mode::Move(_) => "MOVE", + Mode::Command(_) => "COMMAND", + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 70948c4..50a7f86 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,9 @@ //! | [`storage`] | files: documents (RON) | //! | [`view`] | the views, one module each: so far, the canvas | //! | [`ui`] | what views share: glyphs, theme, which-key, the command line | +//! | [`app`] | around the views: actions, modes, buffers, shared commands | +pub mod app; pub mod command; pub mod input; pub mod model; diff --git a/src/model/edit.rs b/src/model/edit.rs index 91443b9..5d2b6f3 100644 --- a/src/model/edit.rs +++ b/src/model/edit.rs @@ -79,9 +79,16 @@ impl Change { } /// The nodes this change touched, so undo and redo can show where it - /// happened. + /// happened: the nodes it added or removed first, then the others (like + /// a parent whose list of children changed). pub fn nodes(&self) -> impl Iterator + '_ { - self.before.entries.keys().copied() + let came_or_went = |id: &NodeId| { + let existed = |side: &Snapshot| side.entries.get(id).is_some_and(Option::is_some); + existed(&self.before) != existed(&self.after) + }; + let (first, rest): (Vec, Vec) = + self.before.entries.keys().copied().partition(came_or_went); + first.into_iter().chain(rest) } /// This change followed by `next`, as one undo step: for anything both diff --git a/src/model/history.rs b/src/model/history.rs index 44c1d60..4941323 100644 --- a/src/model/history.rs +++ b/src/model/history.rs @@ -1,5 +1,7 @@ //! Undo and redo, for one document. +use std::sync::atomic::{AtomicU64, Ordering}; + use super::document::Document; use super::edit::Change; @@ -9,12 +11,40 @@ use super::edit::Change; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct StateId(u64); +/// Changes made under one group make one undo step: a mouse drag is many +/// small moves, and undoes in one go. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Group(u64); + +impl Group { + /// A new group, different from every other one. + pub fn new() -> Group { + static NEXT: AtomicU64 = AtomicU64::new(1); + Group(NEXT.fetch_add(1, Ordering::Relaxed)) + } +} + +impl Default for Group { + fn default() -> Group { + Group::new() + } +} + +/// One undo step: a change, the state it leads to, and its group if it has +/// one. +#[derive(Debug)] +struct Step { + state: StateId, + change: Change, + group: Option, +} + /// The undo history of one document: the changes that can be undone (newest /// last), and the ones undone that can be redone until the next new change. #[derive(Debug, Default)] pub struct History { - done: Vec<(StateId, Change)>, - undone: Vec<(StateId, Change)>, + done: Vec, + undone: Vec, last_state: u64, } @@ -23,34 +53,51 @@ impl History { /// are skipped, and a new change means what was undone can't be redone /// anymore. pub fn record(&mut self, change: Change) { + self.record_step(change, None); + } + + /// Remembers a change made as part of a group: while the newest undo step + /// belongs to the same group, the change joins it. + pub fn record_in(&mut self, change: Change, group: Group) { + self.record_step(change, Some(group)); + } + + fn record_step(&mut self, change: Change, group: Option) { if change.is_empty() { return; } self.last_state += 1; - self.done.push((StateId(self.last_state), change)); + let state = StateId(self.last_state); self.undone.clear(); + match self.done.last_mut() { + Some(last) if group.is_some() && last.group == group => { + last.change = std::mem::take(&mut last.change).then(change); + last.state = state; + } + _ => self.done.push(Step { state, change, group }), + } } /// Undoes the newest change, and returns it so the caller can show where /// it happened. pub fn undo(&mut self, doc: &mut Document) -> Option<&Change> { - let (state, change) = self.done.pop()?; - doc.restore(change.before()); - self.undone.push((state, change)); - self.undone.last().map(|(_, change)| change) + let step = self.done.pop()?; + doc.restore(step.change.before()); + self.undone.push(step); + self.undone.last().map(|step| &step.change) } /// Redoes the newest undone change, and returns it. pub fn redo(&mut self, doc: &mut Document) -> Option<&Change> { - let (state, change) = self.undone.pop()?; - doc.restore(change.after()); - self.done.push((state, change)); - self.done.last().map(|(_, change)| change) + let step = self.undone.pop()?; + doc.restore(step.change.after()); + self.done.push(step); + self.done.last().map(|step| &step.change) } /// The state the document is in now. pub fn state(&self) -> StateId { - self.done.last().map_or(StateId::default(), |(state, _)| *state) + self.done.last().map_or(StateId::default(), |step| step.state) } /// Whether there's a change to undo. @@ -145,4 +192,28 @@ mod tests { fn doc_root() -> NodeId { NodeId::new(1) } + + #[test] + fn a_group_undoes_in_one_step() { + let (mut doc, g) = crate::model::test_support::garden(); + let start = doc.clone(); + let mut history = History::default(); + let drag = Group::new(); + for _ in 0..3 { + let op = crate::model::EditOp::Shift { + id: g.beds, + offset: crate::model::CanvasOffset::new(1, 0), + branch: true, + }; + let (_, change) = doc.apply(&op).unwrap(); + history.record_in(change, drag); + } + let (_, other) = + doc.apply(&crate::model::EditOp::SetFold { ids: vec![g.beds], fold: Fold::Closed }).unwrap(); + history.record(other); + history.undo(&mut doc); + history.undo(&mut doc); + assert_eq!(doc, start, "three moves, one step"); + assert!(!history.can_undo()); + } } diff --git a/src/model/mod.rs b/src/model/mod.rs index 494e70d..a497f0e 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -19,6 +19,7 @@ mod ids; mod link; mod look; mod node; +mod op; mod portal; mod text; @@ -26,11 +27,12 @@ pub use document::{Document, NodeRecord, Place, StructureError}; pub use edit::{Change, Edit, EditError, Spot}; pub use fold::Visible; pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction}; -pub use history::{History, StateId}; +pub use history::{Group, History, StateId}; pub use ids::{DocUid, NodeId}; pub use link::{Link, LinkKey}; pub use look::{Arrows, Border, LineColor, LineLook, NodeLook, Stroke}; pub use node::{Content, Fold, Idea, Node}; +pub use op::{Applied, EditOp, Inbox}; pub use portal::{Backlink, EmptyPath, LinkPath, Portal}; pub use text::{Body, ColorName, Label, StyleName, Tag, Tags, TextError, Title}; diff --git a/src/model/op.rs b/src/model/op.rs new file mode 100644 index 0000000..563f455 --- /dev/null +++ b/src/model/op.rs @@ -0,0 +1,205 @@ +//! Changes to a document, as data. A view describes the change it wants as +//! an [`EditOp`], and the app applies it with [`Document::apply`]: views +//! never touch the document themselves. + +use super::document::Document; +use super::edit::{Change, Edit, EditError, Spot}; +use super::geometry::CanvasOffset; +use super::ids::NodeId; +use super::link::{Link, LinkKey}; +use super::look::Border; +use super::node::{Content, Fold, Node}; +use super::text::{ColorName, Tag, Title}; + +/// One change to a document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EditOp { + /// A new node at a spot in the tree. + Add { + node: Node, + spot: Spot, + }, + /// An idea for the Inbox, a loose node that's made the first time. + Capture { + idea: Node, + inbox: Inbox, + }, + /// Nodes with their branches (`dd`, `3dd`, `dib`). + RemoveBranches(Vec), + /// Just this node; its children take its place (`x`). + RemoveKeepingChildren(NodeId), + /// A node, with its branch, to another spot in the tree. + MoveTo { + id: NodeId, + spot: Spot, + }, + /// A node moves on the canvas, with its branch when `branch` is set. + Shift { + id: NodeId, + offset: CanvasOffset, + branch: bool, + }, + /// How some branches fold. + SetFold { + ids: Vec, + fold: Fold, + }, + /// A node's color (`None`: whatever the theme says). + SetColor { + id: NodeId, + color: Option, + }, + SetBorder { + id: NodeId, + border: Option, + }, + /// An idea's title (a portal's title comes from its document). + SetTitle { + id: NodeId, + title: Title, + }, + /// Tags added to and removed from a node. + Tag { + id: NodeId, + add: Vec, + remove: Vec, + }, + Link(Link), + Unlink(Vec), +} + +/// The Inbox an idea goes into: the one there is, or a new one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Inbox { + Existing(NodeId), + New(Node), +} + +/// What an applied op did, for the cursor and the status bar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Applied { + /// A node was added. + Added(NodeId), + /// This many nodes were removed, branches included. + Removed(usize), + /// Something else changed. + Changed, +} + +impl Document { + /// Applies an op as one transaction (see [`Document::edit`]): on an + /// error, nothing changes. + pub fn apply(&mut self, op: &EditOp) -> Result<(Applied, Change), EditError> { + self.edit(|edit| apply(edit, op)) + } +} + +fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result { + match op { + EditOp::Add { node, spot } => edit.add(node.clone(), *spot).map(Applied::Added), + EditOp::Capture { idea, inbox } => { + let inbox = match inbox { + Inbox::Existing(id) => *id, + Inbox::New(node) => edit.add(node.clone(), Spot::Loose)?, + }; + edit.add(idea.clone(), Spot::LastChildOf(inbox)).map(Applied::Added) + } + EditOp::RemoveBranches(ids) => { + let mut removed = 0; + for &id in ids { + // A branch inside one removed earlier is already gone. + if edit.doc().contains(id) { + removed += edit.remove_branch(id)?.len(); + } + } + Ok(Applied::Removed(removed)) + } + EditOp::RemoveKeepingChildren(id) => edit.remove_keeping_children(*id).map(|()| Applied::Removed(1)), + EditOp::MoveTo { id, spot } => edit.move_to(*id, *spot).map(|()| Applied::Changed), + EditOp::Shift { id, offset, branch: true } => { + edit.shift_branch(*id, *offset).map(|()| Applied::Changed) + } + EditOp::Shift { id, offset, branch: false } => { + edit.update(*id, |node| node.pos += *offset).map(|()| Applied::Changed) + } + EditOp::SetFold { ids, fold } => { + for &id in ids { + edit.update(id, |node| node.fold = *fold)?; + } + Ok(Applied::Changed) + } + EditOp::SetColor { id, color } => { + edit.update(*id, |node| node.look.color = color.clone()).map(|()| Applied::Changed) + } + EditOp::SetBorder { id, border } => { + edit.update(*id, |node| node.look.border = *border).map(|()| Applied::Changed) + } + EditOp::SetTitle { id, title } => edit + .update(*id, |node| { + if let Content::Idea(idea) = &mut node.content { + idea.title = title.clone(); + } + }) + .map(|()| Applied::Changed), + EditOp::Tag { id, add, remove } => edit + .update(*id, |node| { + for tag in add { + node.tags.insert(tag.clone()); + } + for tag in remove { + node.tags.remove(tag); + } + }) + .map(|()| Applied::Changed), + EditOp::Link(link) => edit.link(link.clone()).map(|_| Applied::Changed), + EditOp::Unlink(keys) => { + for &key in keys { + edit.unlink(key)?; + } + Ok(Applied::Changed) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::CanvasPos; + use crate::model::test_support::{garden, idea, title}; + + #[test] + fn ops_apply_as_one_undo_step_each() { + let (mut doc, g) = garden(); + let start = doc.clone(); + let (applied, change) = doc.apply(&EditOp::RemoveBranches(vec![g.soil, g.beds, g.cedar])).unwrap(); + assert_eq!(applied, Applied::Removed(3), "Cedar went with its parent"); + let mut history = crate::model::History::default(); + history.record(change); + history.undo(&mut doc); + assert_eq!(doc, start); + } + + #[test] + fn capture_makes_the_inbox_once() { + let (mut doc, _) = garden(); + let inbox = Node::idea(title("Inbox"), CanvasPos::new(0, 20)); + let (Applied::Added(first), _) = + doc.apply(&EditOp::Capture { idea: idea("compost bin"), inbox: Inbox::New(inbox) }).unwrap() + else { + panic!("an idea was added"); + }; + let inbox_id = doc.parent(first).unwrap(); + assert_eq!(doc.node(inbox_id).unwrap().title().as_str(), "Inbox"); + doc.apply(&EditOp::Capture { idea: idea("worms"), inbox: Inbox::Existing(inbox_id) }).unwrap(); + assert_eq!(doc.children(inbox_id).len(), 2); + } + + #[test] + fn a_failing_op_changes_nothing() { + let (mut doc, g) = garden(); + let start = doc.clone(); + let op = EditOp::SetFold { ids: vec![g.beds, NodeId::new(99)], fold: Fold::Closed }; + assert_eq!(doc.apply(&op), Err(EditError::NoSuchNode(NodeId::new(99)))); + assert_eq!(doc, start); + } +} diff --git a/src/view/canvas/mod.rs b/src/view/canvas/mod.rs index 8771d32..ec0a706 100644 --- a/src/view/canvas/mod.rs +++ b/src/view/canvas/mod.rs @@ -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; diff --git a/src/view/canvas/placement.rs b/src/view/canvas/placement.rs index 6f6f4f6..17acc6e 100644 --- a/src/view/canvas/placement.rs +++ b/src/view/canvas/placement.rs @@ -19,12 +19,22 @@ pub fn child_spot( size: CanvasSize, rects: &BTreeMap, ) -> 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, +) -> 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 diff --git a/src/view/canvas/render.rs b/src/view/canvas/render.rs index 405d056..261e5a9 100644 --- a/src/view/canvas/render.rs +++ b/src/view/canvas/render.rs @@ -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![ diff --git a/src/view/canvas/scene.rs b/src/view/canvas/scene.rs index a74d711..5ccf184 100644 --- a/src/view/canvas/scene.rs +++ b/src/view/canvas/scene.rs @@ -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. diff --git a/src/view/canvas/view.rs b/src/view/canvas/view.rs new file mode 100644 index 0000000..fa08dc6 --- /dev/null +++ b/src/view/canvas/view.rs @@ -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, + /// 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, +} + +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 { + 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> { + 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 { + 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 { + 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> { + 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 { + 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) -> 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)); + } +} diff --git a/src/view/mod.rs b/src/view/mod.rs index 40ebf77..241b913 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -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 { + 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>; + + /// A mouse gesture over the view. + fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec; +} + +#[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 } + } + } +}