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 } + } + } +}