diff --git a/Cargo.lock b/Cargo.lock index ab921ce..c69bf1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,7 @@ dependencies = [ "ron", "serde", "thiserror", + "toml", "unicode-width", ] @@ -253,6 +254,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.7" @@ -652,6 +663,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -785,6 +805,45 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "typeid" version = "1.0.3" @@ -862,3 +921,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" diff --git a/Cargo.toml b/Cargo.toml index be3cee8..e16a234 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ publish = false # Document files are RON (Rust Object Notation), read and written through serde. serde = { version = "1", features = ["derive"] } ron = "0.12" +# Config files are TOML: keys.toml so far, config.toml and themes later. +toml = "1" # Error types with readable messages, for the parts that report problems. thiserror = "2" # Terminal user interface. The crossterm backend works on Windows, Linux and diff --git a/DESIGN.md b/DESIGN.md index 108478f..29ff234 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -335,10 +335,12 @@ which_key_delay = "300ms" max_node_width = 30 glyphs = "unicode" # "ascii" for fonts without box-drawing characters -# keys.toml +# keys.toml: sections motions, normal, canvas, outline, note, visual, move, +# connect, list; commands by name (src/input/command.rs); "nothing" unbinds leader = "" [normal] "x" = "delete-branch" +"x" = "nothing" [canvas] "H" = "previous-document" diff --git a/README.md b/README.md index 7794e8f..ed2311e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,6 @@ cargo fmt # format the code While the app is being built, the examples try out each part: ```sh -cargo run --example canvas # the canvas, on a sample document +cargo run --example canvas # the canvas with vim keys and which-key cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text ``` diff --git a/examples/canvas.rs b/examples/canvas.rs index 09d5b8b..46af482 100644 --- a/examples/canvas.rs +++ b/examples/canvas.rs @@ -1,50 +1,68 @@ -//! A playground for the canvas engine (stage 2 of the build): a sample -//! document you can move around, zoom, fold and rearrange. The real keys, -//! modes and command line come in later stages; this has just enough to try -//! the canvas. +//! A playground for the canvas (stages 2 and 3 of the build): a sample +//! document to move around, zoom, fold and rearrange, with the real vim keys +//! and the which-key popup. The command line, text editing and the other +//! views come in later stages. //! -//! Run it with `cargo run --example canvas`. +//! Run it with `cargo run --example canvas`. A few keys to start with: //! -//! | Keys | What they do | -//! |----------------|-----------------------------------------------------| -//! | `hjkl`, arrows | move to the nearest node that way | -//! | `gh gl gj gk` | parent, first child, next / previous sibling | -//! | `gg` | the root | -//! | `HJKL` | pan the view | -//! | `+` `-` | zoom in / out | -//! | `za` `zf` `zi` | fold or unfold, auto-fold, focus | -//! | `zz` | center the view on the cursor | -//! | `a` `o` | add a child / a sibling below ("new idea") | -//! | `x` `dd` | delete the node (its children move up) / the branch | -//! | `M` | move mode: hjkl moves the branch, Enter or Esc ends | -//! | `u` `Ctrl-r` | undo / redo | -//! | `q` | quit | +//! | Keys | What they do | +//! |----------------|------------------------------------------------------| +//! | `hjkl`, arrows | move to the nearest node that way (`5j`: five times) | +//! | `g` | go to: `gh gl gj gk` the tree, `gg` the root | +//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` | +//! | `+` `-` | zoom in / out | +//! | `a` `o` `O` | add a child / a sibling below / above | +//! | `x` `dd` `dib` | delete the node / the branch / the children | +//! | `M` | move mode: hjkl moves the branch, Enter ends | +//! | `u` `Ctrl-r` | undo / redo | +//! | `` | the action menu (`w`: key hints on or off) | +//! | `q`, `ZZ` | quit | //! //! Mouse: click selects, dragging a node moves its branch, dragging empty //! space pans, the wheel scrolls. use std::io; +use std::time::{Duration, Instant}; +use cumulus::input::bindings::Context; +use cumulus::input::command::{Bound, Command, Motion, Operator, Target, help_for}; +use cumulus::input::engine::{KeyEngine, Outcome, Typed}; +use cumulus::input::key::Key; +use cumulus::input::keymap::Keymap; +use cumulus::input::mouse::{Cell, Gesture, MouseReader}; use cumulus::model::{ - CanvasOffset, Change, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title, Visible, + CanvasOffset, Change, Direction, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title, + Visible, }; use cumulus::storage::document_file; -use cumulus::ui::glyphs::Direction; 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}; use cumulus::view::canvas::{Camera, Scene, SceneOptions, ScreenPos}; -use ratatui::crossterm::event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, - MouseButton, MouseEvent, MouseEventKind, -}; +use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Paragraph; use ratatui::{DefaultTerminal, Frame}; +/// The playground's own keys.toml: `q` quits. (cumulus itself keeps `q` +/// free for macros; this shows how keys.toml changes a key.) +const KEYS_TOML: &str = r#" +[normal] +"q" = "close" +"#; + +/// How long a sequence waits before its which-key popup shows (the leader's +/// shows right away). +const WHICH_KEY_DELAY: Duration = Duration::from_millis(300); + +const NORMAL: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions]; +const MOVING: &[Context] = &[Context::Move]; + fn main() -> io::Result<()> { let doc = document_file::parse(include_str!("garden.cum")).map_err(io::Error::other)?; let mut terminal = ratatui::try_init()?; @@ -61,10 +79,20 @@ 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, + }, +} + /// A mouse drag in progress. enum Drag { - /// Moving a node's branch: where the drag started, how far the branch has - /// moved so far, and the undo step for the whole 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 }, @@ -77,11 +105,11 @@ struct Playground { camera: Camera, options: SceneOptions, theme: Theme, - /// The first key of a two-key command (`g`, `z`, `d`), waiting for the - /// second. - pending: Option, - /// In move mode: the undo step for the whole move so far. - moving: Option>, + keymap: Keymap, + keys: KeyEngine, + which_key: bool, + mode: Mode, + mouse: MouseReader, drag: Option, /// The last frame drawn, to match clicks against. scene: Scene, @@ -93,6 +121,7 @@ struct Playground { impl Playground { fn new(doc: Document) -> Playground { + let (keymap, problems) = Keymap::from_toml(KEYS_TOML); Playground { cursor: doc.root(), doc, @@ -100,32 +129,50 @@ impl Playground { camera: Camera::default(), options: SceneOptions::default(), theme: Theme::default(), - pending: None, - moving: None, + keymap, + keys: KeyEngine::default(), + which_key: true, + mode: Mode::Normal, + mouse: MouseReader::default(), drag: None, scene: Scene::default(), canvas_area: Rect::default(), follow: true, - message: String::new(), + message: problems.join(" · "), } } fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { loop { terminal.draw(|frame| self.draw(frame))?; + // While a sequence is half typed, wake up now and then: its + // which-key popup may be due. + let wait = + if self.keys.is_pending() { Duration::from_millis(50) } else { Duration::from_secs(3600) }; + if !event::poll(wait)? { + continue; + } match event::read()? { // Windows reports key releases too: only presses and repeats count. - Event::Key(key) if key.kind != KeyEventKind::Release => { - if !self.key(key) { + Event::Key(event) if event.kind != KeyEventKind::Release => { + if let Some(key) = Key::from_event(event) + && !self.key(key) + { return Ok(()); } } - Event::Mouse(mouse) => self.mouse(mouse), + Event::Mouse(event) => { + if let Some(gesture) = self.mouse.read(event, Instant::now()) { + self.gesture(gesture); + } + } _ => {} } } } + // ----- Drawing -------------------------------------------------------- + fn draw(&mut self, frame: &mut Frame) { let [canvas, status] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area()); @@ -140,131 +187,208 @@ impl Playground { } } frame.render_widget(&self.scene, canvas); - frame.render_widget(self.status_line(), status); + if self.which_key_due() + && let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts())) + { + frame.render_widget(WhichKey { hints: &hints, theme: &self.theme }, canvas); + } + self.draw_status(frame, status); } fn build_scene(&self, area: Rect) -> Scene { Scene::build(&self.doc, self.cursor, self.camera, area.as_size(), &self.theme, self.options) } - fn status_line(&self) -> Line<'static> { - let mode = if self.moving.is_some() { " MOVE " } else { " CANVAS " }; + /// 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 · menu · q quit", + ), + Mode::Move { whole: true, .. } => { + (" MOVE ", "hjkl moves the branch (HJKL by 5) · M just this node · Enter done") + } + Mode::Move { whole: false, .. } => { + (" MOVE ", "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done") + } + }; let title = self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default(); let focus = if self.options.focus { " · focus" } else { "" }; - let hints = if self.message.is_empty() { - "hjkl move · gh/gl tree · +/- zoom · za/zf/zi fold · a/o add · x/dd delete · M move · u undo · q quit" - .to_string() - } else { - self.message.clone() - }; - let pending = self.pending.map(|key| format!(" {key}")).unwrap_or_default(); - Line::from(vec![ + let middle = if self.message.is_empty() { hints.dark_gray() } else { self.message.clone().yellow() }; + let line = Line::from(vec![ mode.black().on_cyan().bold(), format!(" {}{focus} │ {title} │ ", self.camera.zoom.name()).into(), - hints.dark_gray(), - pending.cyan().bold(), - ]) + middle, + ]); + frame.render_widget(Paragraph::new(line), area); + let typed = Line::from(format!("{} ", self.keys.shown()).cyan().bold()); + frame.render_widget(Paragraph::new(typed).right_aligned(), area); } - /// Handles a key. Returns false to quit. - fn key(&mut self, key: KeyEvent) -> bool { + fn which_key_due(&self) -> bool { + let delay = if self.keys.first_key() == Some(self.keymap.leader()) { + Duration::ZERO + } else { + WHICH_KEY_DELAY + }; + self.which_key && self.keys.pending_since().is_some_and(|since| since.elapsed() >= delay) + } + + // ----- Keys ----------------------------------------------------------- + + fn contexts(&self) -> &'static [Context] { + match self.mode { + Mode::Normal => NORMAL, + Mode::Move { .. } => MOVING, + } + } + + /// Handles a key press. Returns false to quit. + fn key(&mut self, key: Key) -> bool { self.message.clear(); - if self.moving.is_some() { - self.move_key(key); - return true; - } - if let Some(first) = self.pending.take() { - if let KeyCode::Char(second) = key.code { - self.sequence(first, second); - } - return true; - } - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - match key.code { - KeyCode::Char('q') => return false, - KeyCode::Char('c') if ctrl => return false, - KeyCode::Char('r') if ctrl => self.redo(), - KeyCode::Char('h') | KeyCode::Left => self.go(Direction::Left), - KeyCode::Char('j') | KeyCode::Down => self.go(Direction::Down), - KeyCode::Char('k') | KeyCode::Up => self.go(Direction::Up), - KeyCode::Char('l') | KeyCode::Right => self.go(Direction::Right), - KeyCode::Char('H') => self.camera.pan(-8, 0), - KeyCode::Char('J') => self.camera.pan(0, 4), - KeyCode::Char('K') => self.camera.pan(0, -4), - KeyCode::Char('L') => self.camera.pan(8, 0), - KeyCode::Char('+' | '=') => self.zoom(true), - KeyCode::Char('-') => self.zoom(false), - KeyCode::Char('a') => self.add_child(), - KeyCode::Char('o') => self.add_below(), - KeyCode::Char('x') => self.delete_node(), - KeyCode::Char('u') => self.undo(), - KeyCode::Char('M') => { - self.moving = Some(None); - self.message = "hjkl moves the branch (HJKL by 5) · Enter or Esc ends".into(); - } - KeyCode::Char(first @ ('g' | 'z' | 'd')) => self.pending = Some(first), - _ => {} + 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 } - /// The second key of `g…`, `z…` and `d…`. - fn sequence(&mut self, first: char, second: char) { - match (first, second) { - ('g', 'g') => self.jump(Some(self.doc.root())), - ('g', 'h') => self.jump(self.doc.parent(self.cursor)), - ('g', 'l') => self.jump(self.doc.children(self.cursor).first().copied()), - ('g', 'j') => self.jump(self.doc.next_sibling(self.cursor)), - ('g', 'k') => self.jump(self.doc.previous_sibling(self.cursor)), - ('z', 'a') => self.toggle_fold(), - ('z', 'f') => self.set_fold(Fold::Auto), - ('z', 'i') => { + /// 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_branch(), + 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" }); } - ('z', 'z') => self.center(), - ('d', 'd') => self.delete_branch(), - _ => self.message = format!("{first}{second} does nothing here"), + 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::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 move_key(&mut self, key: KeyEvent) { - let (cols, rows) = match key.code { - KeyCode::Char('h') | KeyCode::Left => (-1, 0), - KeyCode::Char('l') | KeyCode::Right => (1, 0), - KeyCode::Char('j') | KeyCode::Down => (0, 1), - KeyCode::Char('k') | KeyCode::Up => (0, -1), - KeyCode::Char('H') => (-5, 0), - KeyCode::Char('L') => (5, 0), - KeyCode::Char('J') => (0, 5), - KeyCode::Char('K') => (0, -5), - KeyCode::Enter | KeyCode::Esc | KeyCode::Char('M') => { - if let Some(Some(change)) = self.moving.take() { + 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; + } + }; + 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; } - _ => return, }; let (id, offset) = (self.cursor, self.camera.cells(cols, rows)); - if let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, offset)) { - let so_far = self.moving.take().flatten(); - self.moving = Some(Some(match so_far { - Some(change) => change.then(next), + 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; } } - fn mouse(&mut self, mouse: MouseEvent) { + // ----- The mouse ------------------------------------------------------ + + fn gesture(&mut self, gesture: Gesture) { let area = self.canvas_area; - let at = ScreenPos::new( - i32::from(mouse.column) - i32::from(area.x), - i32::from(mouse.row) - i32::from(area.y), - ); - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { + 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; @@ -273,40 +397,49 @@ impl Playground { None => Drag::Pan { last: at }, }); } - MouseEventKind::Drag(MouseButton::Left) => 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 => {} - }, - MouseEventKind::Up(MouseButton::Left) => { + 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); } } - MouseEventKind::ScrollDown => self.camera.pan(0, 3), - MouseEventKind::ScrollUp => self.camera.pan(0, -3), - MouseEventKind::ScrollRight => self.camera.pan(6, 0), - MouseEventKind::ScrollLeft => self.camera.pan(-6, 0), - _ => {} + 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); @@ -358,30 +491,27 @@ impl Playground { self.message = format!("fold: {fold:?}"); } - fn add_child(&mut self) { - let Some(node) = self.new_idea() else { return }; - 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 parent = self.cursor; - let node = Node { pos: child_spot(&self.doc, parent, size, &rects), ..node }; - let added = self.edit(|edit| edit.add(node, Spot::LastChildOf(parent))); - self.jump(added); + 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))); } - fn add_below(&mut self) { - let Some(node) = self.new_idea() else { return }; + /// 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 sibling = self.cursor; - let node = Node { pos: below_spot(sibling, size, &rects), ..node }; - let added = self.edit(|edit| edit.add(node, Spot::After(sibling))); + 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 new_idea(&self) -> Option { - Title::parse("new idea").ok().map(|title| Node::idea(title, Default::default())) - } - fn delete_node(&mut self) { let id = self.cursor; let next = self.doc.children(id).first().copied().or_else(|| self.doc.parent(id)); @@ -399,6 +529,20 @@ impl Playground { } } + 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(), diff --git a/src/input/bindings.rs b/src/input/bindings.rs new file mode 100644 index 0000000..9bd6306 --- /dev/null +++ b/src/input/bindings.rs @@ -0,0 +1,321 @@ +//! The default keys, one table per place they work. To change a key for +//! everyone, change it here; to change it just for you, use `keys.toml`. +//! Which-key and the help read these same tables, so they can't drift apart. +//! +//! Keys are written in vim notation (see [`key`](super::key)); `` is +//! the leader key, `` unless `keys.toml` says otherwise. + +use super::command::{Bound, CharKey, Command as C, Motion as M, Operator as Op, Target, TargetBound}; + +/// Where a table's keys work. Each is a section in `keys.toml`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Context { + /// Keys that move the cursor, in normal, visual and connect modes. + Motions, + /// Normal mode, in every view. + Normal, + /// Normal mode on the canvas, on top of `Normal`. + Canvas, + /// Normal mode in the outline, on top of `Normal`. + Outline, + /// Normal mode in the Note view, on top of `Normal`. + Note, + Visual, + Move, + Connect, + /// Lists in popups: pickers, menus, the help. + List, +} + +impl Context { + pub const ALL: [Context; 9] = [ + Context::Motions, + Context::Normal, + Context::Canvas, + Context::Outline, + Context::Note, + Context::Visual, + Context::Move, + Context::Connect, + Context::List, + ]; + + /// The section's name in `keys.toml`. + pub fn name(self) -> &'static str { + match self { + Context::Motions => "motions", + Context::Normal => "normal", + Context::Canvas => "canvas", + Context::Outline => "outline", + Context::Note => "note", + Context::Visual => "visual", + Context::Move => "move", + Context::Connect => "connect", + Context::List => "list", + } + } + + /// The context with this section name in `keys.toml`. + pub fn named(name: &str) -> Option { + Context::ALL.into_iter().find(|context| context.name() == name) + } + + /// The default keys of this context. + pub fn defaults(self) -> &'static [DefaultBinding] { + match self { + Context::Motions => MOTIONS, + Context::Normal => NORMAL, + Context::Canvas => CANVAS, + Context::Outline | Context::Note => &[], + Context::Visual => VISUAL, + Context::Move => MOVE, + Context::Connect => CONNECT, + Context::List => LIST, + } + } +} + +/// The tables that work together in each mode and view, most specific first: +/// when two tables bind the same keys, the first one wins. +pub const SITUATIONS: &[(&str, &[Context])] = &[ + ("normal mode on the canvas", &[Context::Canvas, Context::Normal, Context::Motions]), + ("normal mode in the outline", &[Context::Outline, Context::Normal, Context::Motions]), + ("normal mode in the Note view", &[Context::Note, Context::Normal, Context::Motions]), + ("visual mode", &[Context::Visual, Context::Canvas, Context::Motions]), + ("move mode", &[Context::Move]), + ("connect mode", &[Context::Connect, Context::Canvas, Context::Motions]), + ("lists", &[Context::List]), +]; + +/// A default binding: keys in notation, what they do, and how to say it +/// here when the command's own words don't fit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DefaultBinding { + pub keys: &'static str, + pub bound: Bound, + pub help: Option<&'static str>, +} + +const fn bind(keys: &'static str, command: C) -> DefaultBinding { + DefaultBinding { keys, bound: Bound::Command(command), help: None } +} + +const fn bind_help(keys: &'static str, command: C, help: &'static str) -> DefaultBinding { + DefaultBinding { keys, bound: Bound::Command(command), help: Some(help) } +} + +const fn operator(keys: &'static str, operator: Op) -> DefaultBinding { + DefaultBinding { keys, bound: Bound::Operator(operator), help: None } +} + +const fn with_char(keys: &'static str, key: CharKey) -> DefaultBinding { + DefaultBinding { keys, bound: Bound::WithChar(key), help: None } +} + +const fn go(keys: &'static str, motion: M) -> DefaultBinding { + bind(keys, C::Move(motion)) +} + +/// Moving the cursor: in normal, visual and connect modes. +const MOTIONS: &[DefaultBinding] = &[ + go("h", M::Left), + go("", M::Left), + go("j", M::Down), + go("", M::Down), + go("k", M::Up), + go("", M::Up), + go("l", M::Right), + go("", M::Right), + go("gh", M::Parent), + go("gl", M::FirstChild), + go("gj", M::NextSibling), + go("gk", M::PreviousSibling), + go("w", M::Next), + go("b", M::Previous), + go("gg", M::First), + go("G", M::Last), + bind("/", C::Search), + go("n", M::SearchNext), + go("N", M::SearchPrevious), + bind("", C::JumpBack), + // Most terminals send Tab for Ctrl-i, so both jump forward, as in vim. + bind("", C::JumpForward), + bind("", C::JumpForward), + with_char("m", CharKey::SetMark), + with_char("'", CharKey::JumpToMark), + with_char("`", CharKey::JumpToMark), + bind("gf", C::FollowPortal), + bind("gd", C::FollowLink), + bind("zz", C::Center), + bind("", C::ScrollDown), + bind("", C::ScrollUp), + bind("zh", C::ScrollLeft), + bind("zl", C::ScrollRight), + bind("", C::HalfPageDown), + bind("", C::HalfPageUp), +]; + +/// Normal mode, in every view. +const NORMAL: &[DefaultBinding] = &[ + bind_help("", C::Activate, "edit the title (a portal: go through it)"), + bind("i", C::EditTitle), + bind("A", C::EditTitle), + bind("I", C::EditTitleAtStart), + bind("cc", C::RetypeTitle), + bind("S", C::RetypeTitle), + bind("o", C::AddBelow), + bind("O", C::AddAbove), + bind("a", C::AddChild), + bind("x", C::DeleteNode), + operator("d", Op::Delete), + operator("y", Op::Yank), + operator(">", Op::Indent), + operator("", Op::Outdent), + bind("p", C::PasteAfter), + bind("P", C::PasteBefore), + bind("]p", C::PasteAsChild), + bind("u", C::Undo), + bind("", C::Redo), + bind(".", C::Repeat), + bind("K", C::ShowBody), + bind("za", C::ToggleFold), + bind("zo", C::OpenFold), + bind("zc", C::CloseFold), + bind("zf", C::AutoFold), + bind("zR", C::OpenAllFolds), + bind("zM", C::CloseAllFolds), + bind("zi", C::ToggleFocus), + bind("v", C::Visual), + bind("V", C::VisualBranches), + bind("M", C::MoveMode), + bind("C", C::ConnectMode), + bind(":", C::CommandLine), + bind("", C::Escape), + bind("", C::Escape), + bind("c", C::PickColor), + bind("s", C::PickStyle), + bind("B", C::PickBorder), + bind("t", C::EditTags), + bind("p", C::NewPortal), + bind("i", C::Capture), + bind("b", C::PickDocument), + bind("vc", C::ShowCanvas), + bind("vo", C::ShowOutline), + bind("vn", C::ShowNote), + bind("w", C::ToggleWhichKey), + bind("?", C::Help), + bind("?", C::Help), + bind("v", C::SplitVertical), + bind("s", C::SplitHorizontal), + bind("h", C::FocusLeft), + bind("j", C::FocusDown), + bind("k", C::FocusUp), + bind("l", C::FocusRight), + bind("w", C::NextPane), + bind("c", C::ClosePane), + bind("q", C::ClosePane), + bind("o", C::OnlyPane), + bind("=", C::EqualizePanes), + bind("gt", C::NextDocument), + bind("gT", C::PreviousDocument), + bind("]b", C::NextDocument), + bind("[b", C::PreviousDocument), + bind("ZZ", C::Close), +]; + +/// Normal mode on the canvas. +const CANVAS: &[DefaultBinding] = &[bind("+", C::ZoomIn), bind("-", C::ZoomOut)]; + +/// Visual mode: operators act on the selection right away. +const VISUAL: &[DefaultBinding] = &[ + bind("o", C::SwapEnds), + bind("d", C::Operate(Op::Delete, Target::Selection)), + bind("x", C::Operate(Op::Delete, Target::Selection)), + bind("y", C::Operate(Op::Yank, Target::Selection)), + bind(">", C::Operate(Op::Indent, Target::Selection)), + bind("", C::Operate(Op::Outdent, Target::Selection)), + bind("M", C::MoveMode), + bind("C", C::ConnectMode), + bind("c", C::PickColor), + bind("s", C::PickStyle), + bind("B", C::PickBorder), + bind("t", C::EditTags), + bind_help("v", C::Visual, "select a rectangle (again: stop)"), + bind_help("V", C::VisualBranches, "select whole branches (again: stop)"), + bind_help("", C::Escape, "stop selecting"), + bind_help("", C::Escape, "stop selecting"), +]; + +/// Move mode: the cursor's node (or branch) moves instead of the cursor. +const MOVE: &[DefaultBinding] = &[ + bind_help("h", C::Move(M::Left), "move it left"), + bind_help("", C::Move(M::Left), "move it left"), + bind_help("j", C::Move(M::Down), "move it down"), + bind_help("", C::Move(M::Down), "move it down"), + bind_help("k", C::Move(M::Up), "move it up"), + bind_help("", C::Move(M::Up), "move it up"), + bind_help("l", C::Move(M::Right), "move it right"), + bind_help("", C::Move(M::Right), "move it right"), + bind("H", C::FarLeft), + bind("J", C::FarDown), + bind("K", C::FarUp), + bind("L", C::FarRight), + bind("M", C::ToggleWhole), + bind_help("", C::Confirm, "done"), + bind_help("", C::Escape, "done"), + bind_help("", C::Escape, "done"), +]; + +/// Connect mode: walk to another node, then link to it. +const CONNECT: &[DefaultBinding] = &[ + bind_help("", C::Confirm, "connect to this node (or save the link)"), + bind("d", C::RemoveLink), + bind(">", C::AttachAsChild), + bind_help("s", C::PickStyle, "the link's style…"), + bind("a", C::CycleArrows), + bind("l", C::CycleLine), + bind_help("c", C::PickColor, "the link's color…"), + bind("t", C::EditLabel), + bind_help("", C::Escape, "cancel"), + bind_help("", C::Escape, "cancel"), +]; + +/// Lists in popups. +const LIST: &[DefaultBinding] = &[ + go("j", M::Down), + go("", M::Down), + go("k", M::Up), + go("", M::Up), + go("gg", M::First), + go("G", M::Last), + bind("", C::HalfPageDown), + bind("", C::HalfPageUp), + bind("/", C::Search), + bind_help("", C::Confirm, "pick"), + bind_help("", C::Escape, "close"), + bind_help("q", C::Escape, "close"), +]; + +/// What an operator can act on, besides its own key again (`dd`: this node +/// and its branch). +pub const NODE_TARGETS: &[(&str, TargetBound, &str)] = &[ + ("ab", TargetBound::Target(Target::Branch), "the branch: this node and all below"), + ("ib", TargetBound::Target(Target::Children), "inside the branch: just what's below"), +]; + +/// Names for key groups: the title of which-key's popup, and its `+group` +/// entries. +pub const GROUPS: &[(&str, &str)] = &[ + ("g", "go to"), + ("z", "view & folds"), + ("", "actions"), + ("v", "show a view"), + ("", "panes"), + ("[", "previous"), + ("]", "next"), + ("Z", "close"), + ("c", "change"), +]; + +/// Names for groups of targets, after an operator. +pub const TARGET_GROUPS: &[(&str, &str)] = &[("a", "around"), ("i", "inside")]; diff --git a/src/input/command.rs b/src/input/command.rs new file mode 100644 index 0000000..2bc1a85 --- /dev/null +++ b/src/input/command.rs @@ -0,0 +1,474 @@ +//! Everything a key can ask for. Views never see raw key presses: the engine +//! turns them into [`Command`]s using the tables in +//! [`bindings`](super::bindings), and each view decides what a command means +//! there. `Move(Left)` goes to the nearest node on the left on the canvas, +//! for example, and to the parent in the outline. +//! +//! [`COMMANDS`] names every command, for `keys.toml`, and says what it does, +//! for which-key and the help. + +/// Ways to move the cursor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Motion { + Left, + Down, + Up, + Right, + Parent, + FirstChild, + NextSibling, + PreviousSibling, + /// The next node in reading order (in text: the next word). + Next, + /// The previous node in reading order (in text: the previous word). + Previous, + /// The root (in text: the start). + First, + /// The last node (in text: the end). + Last, + SearchNext, + SearchPrevious, + /// To the next time a character shows up on the line (`f`, in text). + FindForward(char), + /// To the previous time a character shows up on the line (`F`). + FindBackward(char), + /// To just before the next time a character shows up (`t`). + TillForward(char), + /// To just after the previous time a character shows up (`T`). + TillBackward(char), +} + +/// Keys that wait for a target, like vim's `d`: `dd`, `dab`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Operator { + Delete, + Yank, + Change, + Indent, + Outdent, +} + +impl Operator { + /// What the operator does, for which-key's title. + pub fn name(self) -> &'static str { + match self { + Operator::Delete => "delete", + Operator::Yank => "yank", + Operator::Change => "change", + Operator::Indent => "indent", + Operator::Outdent => "outdent", + } + } +} + +/// What an operator acts on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Target { + /// The operator's own key again (`dd`, `yy`, `>>`): this node, with its + /// branch (in text: this line). + Current, + /// `ab`, around a branch: the node and everything below it. + Branch, + /// `ib`, inside a branch: just what's below the node. + Children, + /// What's selected (an operator in visual mode). + Selection, + /// From the cursor to where a motion goes (`dw`, `dfx`, in text). + Motion(Motion), +} + +/// Keys that need one more character, typed right after them: `m` then a +/// letter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CharKey { + SetMark, + JumpToMark, + FindForward, + FindBackward, + TillForward, + TillBackward, +} + +impl CharKey { + /// The command once the character is known. + pub fn command(self, c: char) -> Command { + match self { + CharKey::SetMark => Command::SetMark(c), + CharKey::JumpToMark => Command::JumpToMark(c), + CharKey::FindForward => Command::Move(Motion::FindForward(c)), + CharKey::FindBackward => Command::Move(Motion::FindBackward(c)), + CharKey::TillForward => Command::Move(Motion::TillForward(c)), + CharKey::TillBackward => Command::Move(Motion::TillBackward(c)), + } + } + + /// As the target of an operator, once the character is known (`dfx`). + /// Only motions can be targets. + pub fn target(self, c: char) -> Option { + match self.command(c) { + Command::Move(motion) => Some(Target::Motion(motion)), + _ => None, + } + } + + /// What the character is for, for which-key: the keys, and what they do. + pub fn hints(self) -> &'static [(&'static str, &'static str)] { + match self { + CharKey::SetMark => &[("a–z", "mark this node"), ("A–Z", "mark it, across documents")], + CharKey::JumpToMark => &[("a–z A–Z", "go to that mark")], + _ => &[("any key", "the character to go to")], + } + } +} + +/// Where text goes and comes from with yank and paste: `"a` before a +/// command picks register `a`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Register { + /// The usual one, when no register is named. + Unnamed, + /// `"a` to `"z` (capitals name the same registers). + Named(char), + /// `"+` (or `"*`): the system clipboard. + Clipboard, + /// `"_`: nowhere, for deleting without keeping a copy. + BlackHole, +} + +impl Register { + /// The register a character names after `"`, if it names one. + pub fn named(c: char) -> Option { + match c { + '"' => Some(Register::Unnamed), + 'a'..='z' => Some(Register::Named(c)), + 'A'..='Z' => Some(Register::Named(c.to_ascii_lowercase())), + '+' | '*' => Some(Register::Clipboard), + '_' => Some(Register::BlackHole), + _ => None, + } + } + + /// The registers, for which-key after `"`. + pub const HINTS: &[(&str, &str)] = &[ + ("a–z", "a named register"), + ("+", "the system clipboard"), + ("_", "nowhere (delete without a copy)"), + ]; +} + +/// Everything a key sequence can ask for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Command { + /// Move the cursor. + Move(Motion), + /// An operator with its target: `dd`, `yab`. + Operate(Operator, Target), + /// Mark the cursor's node with a letter (`ma`). + SetMark(char), + /// Go to a marked node (`'a`). + JumpToMark(char), + + // Jumping around. + Search, + JumpBack, + JumpForward, + FollowPortal, + FollowLink, + /// Enter: edit the title, or go through a portal. + Activate, + + // The view. + Center, + ScrollDown, + ScrollUp, + ScrollLeft, + ScrollRight, + HalfPageDown, + HalfPageUp, + ZoomIn, + ZoomOut, + ToggleFold, + OpenFold, + CloseFold, + AutoFold, + OpenAllFolds, + CloseAllFolds, + ToggleFocus, + ShowBody, + + // Creating and editing. + AddBelow, + AddAbove, + AddChild, + EditTitle, + EditTitleAtStart, + RetypeTitle, + /// `x`: delete just this node; its children move up. + DeleteNode, + PasteAfter, + PasteBefore, + PasteAsChild, + Undo, + Redo, + Repeat, + + // Looks, tags, portals. + PickColor, + PickStyle, + PickBorder, + EditTags, + NewPortal, + Capture, + + // Modes. + Visual, + VisualBranches, + MoveMode, + ConnectMode, + CommandLine, + Escape, + + // In visual, move and connect modes. + SwapEnds, + FarLeft, + FarDown, + FarUp, + FarRight, + ToggleWhole, + Confirm, + RemoveLink, + AttachAsChild, + CycleArrows, + CycleLine, + EditLabel, + + // Panes and documents. + SplitVertical, + SplitHorizontal, + FocusLeft, + FocusDown, + FocusUp, + FocusRight, + NextPane, + ClosePane, + OnlyPane, + EqualizePanes, + NextDocument, + PreviousDocument, + PickDocument, + ShowCanvas, + ShowOutline, + ShowNote, + + // Help and the app. + Help, + ToggleWhichKey, + /// `ZZ`: close the pane (everything is saved already). + Close, +} + +/// What a key sequence is bound to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Bound { + /// A command, run as soon as its keys are typed. + Command(Command), + /// An operator: it waits for a target. + Operator(Operator), + /// A key that waits for one more character. + WithChar(CharKey), +} + +/// What a key sequence typed after an operator means. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TargetBound { + Target(Target), + WithChar(CharKey), +} + +/// A command's name in `keys.toml`, and what it does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CommandInfo { + pub name: &'static str, + pub bound: Bound, + pub help: &'static str, +} + +const fn info(name: &'static str, command: Command, help: &'static str) -> CommandInfo { + CommandInfo { name, bound: Bound::Command(command), help } +} + +const fn motion(name: &'static str, motion: Motion, help: &'static str) -> CommandInfo { + info(name, Command::Move(motion), help) +} + +const fn operator(name: &'static str, operator: Operator, help: &'static str) -> CommandInfo { + CommandInfo { name, bound: Bound::Operator(operator), help } +} + +const fn with_char(name: &'static str, key: CharKey, help: &'static str) -> CommandInfo { + CommandInfo { name, bound: Bound::WithChar(key), help } +} + +use Command as C; +use Motion as M; +use Operator as Op; + +/// Every command keys can be bound to, by name. A name in `keys.toml` is +/// one of these. +pub const COMMANDS: &[CommandInfo] = &[ + // Moving the cursor. + motion("left", M::Left, "left"), + motion("down", M::Down, "down"), + motion("up", M::Up, "up"), + motion("right", M::Right, "right"), + motion("parent", M::Parent, "the parent"), + motion("first-child", M::FirstChild, "the first child"), + motion("next-sibling", M::NextSibling, "the next sibling"), + motion("previous-sibling", M::PreviousSibling, "the previous sibling"), + motion("next", M::Next, "the next node, in reading order"), + motion("previous", M::Previous, "the previous node, in reading order"), + motion("first", M::First, "the root"), + motion("last", M::Last, "the last node"), + motion("search-next", M::SearchNext, "the next match"), + motion("search-previous", M::SearchPrevious, "the previous match"), + with_char("find-forward", CharKey::FindForward, "to a character"), + with_char("find-backward", CharKey::FindBackward, "back to a character"), + with_char("till-forward", CharKey::TillForward, "up to a character"), + with_char("till-backward", CharKey::TillBackward, "back up to a character"), + with_char("set-mark", CharKey::SetMark, "mark this node"), + with_char("jump-to-mark", CharKey::JumpToMark, "go to a mark"), + info("search", C::Search, "search"), + info("jump-back", C::JumpBack, "jump back"), + info("jump-forward", C::JumpForward, "jump forward"), + info("follow-portal", C::FollowPortal, "through the portal"), + info("follow-link", C::FollowLink, "follow a cross-link"), + info("activate", C::Activate, "edit the title, or go through a portal"), + // The view. + info("center", C::Center, "center the view"), + info("scroll-down", C::ScrollDown, "scroll down"), + info("scroll-up", C::ScrollUp, "scroll up"), + info("scroll-left", C::ScrollLeft, "scroll left"), + info("scroll-right", C::ScrollRight, "scroll right"), + info("half-page-down", C::HalfPageDown, "half a screen down"), + info("half-page-up", C::HalfPageUp, "half a screen up"), + info("zoom-in", C::ZoomIn, "zoom in"), + info("zoom-out", C::ZoomOut, "zoom out"), + info("fold-toggle", C::ToggleFold, "fold or unfold"), + info("fold-open", C::OpenFold, "unfold"), + info("fold-close", C::CloseFold, "fold"), + info("fold-auto", C::AutoFold, "fold automatically (open while inside)"), + info("fold-open-all", C::OpenAllFolds, "unfold everything"), + info("fold-close-all", C::CloseAllFolds, "fold everything"), + info("focus", C::ToggleFocus, "focus: fold the other branches"), + info("show-body", C::ShowBody, "show the body"), + // Creating and editing. + info("add-below", C::AddBelow, "new sibling below"), + info("add-above", C::AddAbove, "new sibling above"), + info("add-child", C::AddChild, "new child"), + info("edit-title", C::EditTitle, "edit the title"), + info("edit-title-start", C::EditTitleAtStart, "edit the title from its start"), + info("retype-title", C::RetypeTitle, "retype the title"), + info("delete-node", C::DeleteNode, "delete just this node (children move up)"), + operator("delete", Op::Delete, "delete…"), + operator("yank", Op::Yank, "yank (copy)…"), + operator("change", Op::Change, "change…"), + operator("indent", Op::Indent, "indent…"), + operator("outdent", Op::Outdent, "outdent…"), + info("delete-branch", C::Operate(Op::Delete, Target::Branch), "delete the branch"), + info("delete-children", C::Operate(Op::Delete, Target::Children), "delete the children"), + info("yank-branch", C::Operate(Op::Yank, Target::Branch), "yank the branch"), + info("yank-children", C::Operate(Op::Yank, Target::Children), "yank the children"), + info("delete-selection", C::Operate(Op::Delete, Target::Selection), "delete the selection"), + info("yank-selection", C::Operate(Op::Yank, Target::Selection), "yank the selection"), + info("indent-selection", C::Operate(Op::Indent, Target::Selection), "indent the selection"), + info("outdent-selection", C::Operate(Op::Outdent, Target::Selection), "outdent the selection"), + info("paste-after", C::PasteAfter, "paste after"), + info("paste-before", C::PasteBefore, "paste before"), + info("paste-as-child", C::PasteAsChild, "paste as a child"), + info("undo", C::Undo, "undo"), + info("redo", C::Redo, "redo"), + info("repeat", C::Repeat, "repeat the last change"), + // Looks, tags, portals. + info("pick-color", C::PickColor, "color…"), + info("pick-style", C::PickStyle, "style…"), + info("pick-border", C::PickBorder, "border…"), + info("edit-tags", C::EditTags, "tags…"), + info("new-portal", C::NewPortal, "new portal…"), + info("capture", C::Capture, "capture an idea to the inbox…"), + // Modes. + info("visual", C::Visual, "select"), + info("visual-branches", C::VisualBranches, "select whole branches"), + info("move-mode", C::MoveMode, "move mode"), + info("connect-mode", C::ConnectMode, "connect mode"), + info("command-line", C::CommandLine, "command line"), + info("escape", C::Escape, "cancel"), + // In visual, move and connect modes. + info("swap-ends", C::SwapEnds, "the other end of the selection"), + info("far-left", C::FarLeft, "left by five"), + info("far-down", C::FarDown, "down by five"), + info("far-up", C::FarUp, "up by five"), + info("far-right", C::FarRight, "right by five"), + info("toggle-whole", C::ToggleWhole, "the whole branch, or just this node"), + info("confirm", C::Confirm, "confirm"), + info("remove-link", C::RemoveLink, "remove the link"), + info("attach-as-child", C::AttachAsChild, "attach as a child here"), + info("cycle-arrows", C::CycleArrows, "arrowheads"), + info("cycle-line", C::CycleLine, "line kind"), + info("edit-label", C::EditLabel, "label…"), + // Panes and documents. + info("split-vertical", C::SplitVertical, "split side by side"), + info("split-horizontal", C::SplitHorizontal, "split above and below"), + info("focus-left", C::FocusLeft, "the pane on the left"), + info("focus-down", C::FocusDown, "the pane below"), + info("focus-up", C::FocusUp, "the pane above"), + info("focus-right", C::FocusRight, "the pane on the right"), + info("next-pane", C::NextPane, "the next pane"), + info("close-pane", C::ClosePane, "close the pane"), + info("only-pane", C::OnlyPane, "close the other panes"), + info("equalize-panes", C::EqualizePanes, "even out the panes"), + info("next-document", C::NextDocument, "the next document"), + info("previous-document", C::PreviousDocument, "the previous document"), + info("pick-document", C::PickDocument, "documents…"), + info("show-canvas", C::ShowCanvas, "canvas"), + info("show-outline", C::ShowOutline, "outline"), + info("show-note", C::ShowNote, "note"), + // Help and the app. + info("help", C::Help, "help: every key"), + info("toggle-which-key", C::ToggleWhichKey, "key hints on or off"), + info("close", C::Close, "close the pane"), +]; + +/// The command with this name in `keys.toml`. +pub fn by_name(name: &str) -> Option<&'static CommandInfo> { + COMMANDS.iter().find(|info| info.name == name) +} + +/// What a binding does, in words (for which-key and the help). +pub fn help_for(bound: Bound) -> &'static str { + COMMANDS.iter().find(|info| info.bound == bound).map_or("", |info| info.help) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_unique_kebab_case() { + for (index, info) in COMMANDS.iter().enumerate() { + assert!(info.name.chars().all(|c| c.is_ascii_lowercase() || c == '-'), "{}", info.name); + assert!(COMMANDS[index + 1..].iter().all(|other| other.name != info.name), "{} twice", info.name); + assert!( + COMMANDS[index + 1..].iter().all(|other| other.bound != info.bound), + "{:?} twice", + info.bound + ); + } + } + + #[test] + fn registers_by_name() { + assert_eq!(Register::named('A'), Some(Register::Named('a'))); + assert_eq!(Register::named('*'), Some(Register::Clipboard)); + assert_eq!(Register::named('1'), None); + } +} diff --git a/src/input/engine.rs b/src/input/engine.rs new file mode 100644 index 0000000..05e193c --- /dev/null +++ b/src/input/engine.rs @@ -0,0 +1,437 @@ +//! The key engine reads key presses the way vim does. It collects a count +//! (`5j`), a register (`"a`), a sequence (`gg`), an operator and its target +//! (`dab`), or a key and the character after it (`ma`), and hands over a +//! complete [`Typed`] command. It also says what can come next, for the +//! which-key popup ([`KeyEngine::hints`]). + +use std::time::Instant; + +use super::command::{Bound, CharKey, Command, Operator, Register, Target, TargetBound}; +use super::key::{Key, notation}; +use super::keymap::{Binding, Tables}; + +/// The biggest count. `99999j` would only go somewhere silly. +const MAX_COUNT: u32 = 9999; + +/// A complete command, with what was typed around it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Typed { + pub command: Command, + /// The number typed first, like the 5 in `5j`. Around an operator, both + /// counts multiply: `2d3w` is 6. + pub count: Option, + /// The register named with `"`, like `a` in `"ayy`. + pub register: Option, +} + +impl Typed { + /// How many times to do it: the count, or once. + pub fn times(&self) -> u32 { + self.count.unwrap_or(1) + } +} + +/// What a key press led to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + /// A complete command. + Run(Typed), + /// The keys so far start something: waiting for the next key. + Waiting, + /// Esc dropped what was being typed. + Cancelled, + /// Nothing starts like this. The keys typed (in notation) were dropped. + Unknown(String), +} + +/// What the engine waits for next. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +enum Stage { + /// A command's keys: none yet, or the start of a sequence like `g`. + #[default] + Command, + /// A register's name, after `"`. + Register, + /// The character after a key like `m`. + Char(CharKey), + /// An operator's target, like `ab` after `d`. `count` is the one typed + /// before the operator. + Target { operator: Operator, operator_keys: Vec, count: Option }, + /// The character of a target like `f` (`dfx`), with the counts so far. + TargetChar { operator: Operator, kind: CharKey, count: Option }, +} + +/// Reads key presses into commands. It remembers what's half typed in +/// between. +#[derive(Debug, Default)] +pub struct KeyEngine { + stage: Stage, + /// The count being typed: before the command, or before an operator's + /// target. + count: Option, + register: Option, + /// The keys typed so far in this stage. + keys: Vec, + /// Everything typed so far, as vim's `showcmd` shows it: `2"a`, `dg`. + shown: String, + /// When the first key of the sequence was pressed (for which-key's delay). + since: Option, +} + +/// What can follow the keys typed so far, for the which-key popup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hints { + /// The keys typed and what they lead to: `g · go to`. + pub title: String, + pub entries: Vec, +} + +/// One possible next key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hint { + pub keys: String, + pub help: String, + /// Whether the key opens a group of more keys (shown as `+name`). + pub group: bool, +} + +impl KeyEngine { + /// Reads one key press, using the tables in effect. + pub fn press(&mut self, key: Key, tables: &Tables<'_>) -> Outcome { + if key.is_escape() && self.is_pending() { + self.reset(); + return Outcome::Cancelled; + } + self.shown.push_str(&key.to_string()); + match std::mem::take(&mut self.stage) { + Stage::Command => self.command_key(key, tables), + Stage::Register => match key.as_char().and_then(Register::named) { + Some(register) => { + self.register = Some(register); + Outcome::Waiting + } + None => self.unknown(), + }, + Stage::Char(kind) => match key.as_char() { + Some(c) => self.run(kind.command(c)), + None => self.unknown(), + }, + Stage::Target { operator, operator_keys, count } => { + self.target_key(key, tables, operator, operator_keys, count) + } + Stage::TargetChar { operator, kind, count } => match key.as_char().and_then(|c| kind.target(c)) { + Some(target) => { + self.count = count; + self.run(Command::Operate(operator, target)) + } + None => self.unknown(), + }, + } + } + + /// Forgets anything half typed. + pub fn reset(&mut self) { + *self = KeyEngine::default(); + } + + /// Whether something is half typed: a count, a register, a sequence… + pub fn is_pending(&self) -> bool { + self.stage != Stage::Command + || !self.keys.is_empty() + || self.count.is_some() + || self.register.is_some() + } + + /// The keys typed so far, like vim's `showcmd`: `2"ad`. + pub fn shown(&self) -> &str { + &self.shown + } + + /// When the sequence being typed started (counts don't count), for + /// which-key's delay. + pub fn pending_since(&self) -> Option { + self.since + } + + /// The first key of the sequence being typed (to show the leader menu + /// right away). + pub fn first_key(&self) -> Option { + match &self.stage { + Stage::Target { operator_keys, .. } => operator_keys.first().copied(), + _ => self.keys.first().copied(), + } + } + + /// What can be typed next, for the which-key popup. `None` while nothing + /// that has follow-ups is half typed. + pub fn hints(&self, tables: &Tables<'_>) -> Option { + let fixed = |what: &str, choices: &[(&str, &str)]| Hints { + title: format!("{} · {what}", self.shown), + entries: choices + .iter() + .map(|(keys, help)| Hint { keys: keys.to_string(), help: help.to_string(), group: false }) + .collect(), + }; + match &self.stage { + Stage::Register => Some(fixed("register", Register::HINTS)), + Stage::Char(kind) | Stage::TargetChar { kind, .. } => { + Some(fixed("then a character", kind.hints())) + } + Stage::Command if self.keys.is_empty() => None, + Stage::Command => { + let title = match tables.group_name(&self.keys) { + Some(name) => format!("{} · {name}", self.shown), + None => self.shown.clone(), + }; + let entries = next_keys(&self.keys, &tables.commands_starting_with(&self.keys), tables); + Some(Hints { title, entries }) + } + Stage::Target { operator, operator_keys, .. } => { + let mut entries = Vec::new(); + if self.keys.is_empty() { + let help = "this node, with its branch".to_string(); + entries.push(Hint { keys: notation(operator_keys), help, group: false }); + } + let targets = tables.targets_starting_with(&self.keys); + for target in targets { + let next = &target.keys[..=self.keys.len()]; + let keys = target.keys[self.keys.len()].to_string(); + if entries.iter().any(|hint| hint.keys == keys) { + continue; + } + let hint = if target.keys.len() == next.len() { + Hint { keys, help: target.help.clone(), group: false } + } else { + let name = tables.target_group_name(next).unwrap_or("more"); + Hint { keys, help: format!("+{name}"), group: true } + }; + entries.push(hint); + } + let group = + tables.target_group_name(&self.keys).map(|name| format!(" {name}")).unwrap_or_default(); + Some(Hints { title: format!("{} · {}{group}", self.shown, operator.name()), entries }) + } + } + } + + /// A key while waiting for a command. + fn command_key(&mut self, key: Key, tables: &Tables<'_>) -> Outcome { + if self.keys.is_empty() { + if let Some(count) = with_digit(self.count, key) { + self.count = Some(count); + return Outcome::Waiting; + } + self.since.get_or_insert_with(Instant::now); + if key.as_char() == Some('"') && self.register.is_none() { + self.stage = Stage::Register; + return Outcome::Waiting; + } + } + self.keys.push(key); + let candidates = tables.commands_starting_with(&self.keys); + let Some(exact) = candidates.iter().find(|binding| binding.keys == self.keys) else { + return if candidates.is_empty() { self.unknown() } else { Outcome::Waiting }; + }; + match exact.bound { + Bound::Command(command) => self.run(command), + Bound::Operator(operator) => { + let operator_keys = std::mem::take(&mut self.keys); + self.stage = Stage::Target { operator, operator_keys, count: self.count.take() }; + Outcome::Waiting + } + Bound::WithChar(kind) => { + self.keys.clear(); + self.stage = Stage::Char(kind); + Outcome::Waiting + } + } + } + + /// A key while an operator waits for its target. + fn target_key( + &mut self, + key: Key, + tables: &Tables<'_>, + operator: Operator, + operator_keys: Vec, + count: Option, + ) -> Outcome { + if self.keys.is_empty() + && let Some(target_count) = with_digit(self.count, key) + { + self.count = Some(target_count); + self.stage = Stage::Target { operator, operator_keys, count }; + return Outcome::Waiting; + } + self.keys.push(key); + // The operator's own keys again: this node (or line), as in `dd`. + if self.keys == operator_keys { + self.count = multiply(count, self.count); + return self.run(Command::Operate(operator, Target::Current)); + } + let candidates = tables.targets_starting_with(&self.keys); + if let Some(exact) = candidates.iter().find(|target| target.keys == self.keys) { + return match exact.bound { + TargetBound::Target(target) => { + self.count = multiply(count, self.count); + self.run(Command::Operate(operator, target)) + } + TargetBound::WithChar(kind) => { + self.keys.clear(); + let count = multiply(count, self.count.take()); + self.stage = Stage::TargetChar { operator, kind, count }; + Outcome::Waiting + } + }; + } + if candidates.is_empty() && !operator_keys.starts_with(&self.keys) { + return self.unknown(); + } + self.stage = Stage::Target { operator, operator_keys, count }; + Outcome::Waiting + } + + fn run(&mut self, command: Command) -> Outcome { + let typed = Typed { command, count: self.count, register: self.register }; + self.reset(); + Outcome::Run(typed) + } + + fn unknown(&mut self) -> Outcome { + let shown = std::mem::take(&mut self.shown); + self.reset(); + Outcome::Unknown(shown) + } +} + +/// The count with one more digit typed, if `key` is a digit that continues +/// or starts a count (`0` only continues one: alone, it's a key of its own). +fn with_digit(count: Option, key: Key) -> Option { + let digit = key.as_char()?.to_digit(10)?; + if digit == 0 && count.is_none() { + return None; + } + Some((count.unwrap_or(0) * 10 + digit).min(MAX_COUNT)) +} + +/// Two counts typed around an operator multiply (`2d3w` deletes 6 words). +fn multiply(before: Option, after: Option) -> Option { + match (before, after) { + (None, None) => None, + _ => Some((before.unwrap_or(1) * after.unwrap_or(1)).min(MAX_COUNT)), + } +} + +/// The keys that can follow `typed`, one entry per next key: what it does, +/// or `+group` when more keys follow. +fn next_keys(typed: &[Key], candidates: &[&Binding], tables: &Tables<'_>) -> Vec { + let mut hints: Vec = Vec::new(); + for binding in candidates { + let Some(next) = binding.keys.get(typed.len()) else { continue }; + let keys = next.to_string(); + if hints.iter().any(|hint| hint.keys == keys) { + continue; + } + let hint = if binding.keys.len() == typed.len() + 1 { + Hint { keys, help: binding.help.clone(), group: false } + } else { + let name = tables.group_name(&binding.keys[..=typed.len()]).unwrap_or("more"); + Hint { keys, help: format!("+{name}"), group: true } + }; + hints.push(hint); + } + hints +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::bindings::Context; + use crate::input::command::Motion; + use crate::input::key::parse_keys; + use crate::input::keymap::{DEFAULT_LEADER, Keymap}; + + const NORMAL: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions]; + + /// Types `text` (vim notation) and returns what the last key led to. + fn type_keys(engine: &mut KeyEngine, keymap: &Keymap, text: &str) -> Outcome { + let tables = keymap.tables(NORMAL); + let mut last = Outcome::Waiting; + for key in parse_keys(text, keymap.leader()).unwrap() { + last = engine.press(key, &tables); + } + last + } + + fn run(command: Command, count: Option, register: Option) -> Outcome { + Outcome::Run(Typed { command, count, register }) + } + + #[test] + fn sequences_counts_and_registers() { + let keymap = Keymap::new(DEFAULT_LEADER); + let mut engine = KeyEngine::default(); + assert_eq!(type_keys(&mut engine, &keymap, "g"), Outcome::Waiting); + assert_eq!(type_keys(&mut engine, &keymap, "g"), run(Command::Move(Motion::First), None, None)); + assert_eq!(type_keys(&mut engine, &keymap, "12j"), run(Command::Move(Motion::Down), Some(12), None)); + assert_eq!( + type_keys(&mut engine, &keymap, "\"+p"), + run(Command::PasteAfter, None, Some(Register::Clipboard)) + ); + assert_eq!( + type_keys(&mut engine, &keymap, "3\"ap"), + run(Command::PasteAfter, Some(3), Some(Register::Named('a'))) + ); + assert_eq!(type_keys(&mut engine, &keymap, "gq"), Outcome::Unknown("gq".into())); + assert_eq!(type_keys(&mut engine, &keymap, "z"), Outcome::Cancelled); + assert!(!engine.is_pending()); + } + + #[test] + fn operators_wait_for_their_target() { + let keymap = Keymap::new(DEFAULT_LEADER); + let mut engine = KeyEngine::default(); + let delete = |target, count| run(Command::Operate(Operator::Delete, target), count, None); + assert_eq!(type_keys(&mut engine, &keymap, "dd"), delete(Target::Current, None)); + assert_eq!(type_keys(&mut engine, &keymap, "dab"), delete(Target::Branch, None)); + assert_eq!( + type_keys(&mut engine, &keymap, "2d3d"), + delete(Target::Current, Some(6)), + "counts multiply" + ); + assert_eq!( + type_keys(&mut engine, &keymap, ">>"), + run(Command::Operate(Operator::Indent, Target::Current), None, None) + ); + assert_eq!(type_keys(&mut engine, &keymap, "dx"), Outcome::Unknown("dx".into())); + assert_eq!(type_keys(&mut engine, &keymap, "ma"), run(Command::SetMark('a'), None, None)); + assert_eq!(type_keys(&mut engine, &keymap, "'A"), run(Command::JumpToMark('A'), None, None)); + } + + #[test] + fn which_key_lists_what_can_follow() { + let keymap = Keymap::new(DEFAULT_LEADER); + let tables = keymap.tables(NORMAL); + let mut engine = KeyEngine::default(); + assert_eq!(engine.hints(&tables), None); + + type_keys(&mut engine, &keymap, ""); + let hints = engine.hints(&tables).unwrap(); + assert_eq!(hints.title, " · actions"); + assert!(hints.entries.contains(&Hint { keys: "c".into(), help: "color…".into(), group: false })); + assert!(hints.entries.contains(&Hint { keys: "v".into(), help: "+show a view".into(), group: true })); + assert_eq!(engine.first_key(), Some(DEFAULT_LEADER)); + + engine.reset(); + type_keys(&mut engine, &keymap, "d"); + let hints = engine.hints(&tables).unwrap(); + assert_eq!(hints.title, "d · delete"); + let keys: Vec<&str> = hints.entries.iter().map(|hint| hint.keys.as_str()).collect(); + assert_eq!(keys, ["d", "a", "i"]); + assert!(hints.entries[1].group); + + engine.reset(); + type_keys(&mut engine, &keymap, "\""); + assert_eq!(engine.hints(&tables).unwrap().title, "\" · register"); + } +} diff --git a/src/input/key.rs b/src/input/key.rs new file mode 100644 index 0000000..a7fcd4d --- /dev/null +++ b/src/input/key.rs @@ -0,0 +1,280 @@ +//! One key press, and vim's notation for writing keys down: +//! +//! - letters and symbols as they are: `j`, `G`, `gg`, `:`, `<<` +//! - special keys in angle brackets: `` (Enter), ``, ``, +//! ``, ``, ``, ``, ``, ``, ``, +//! ``, ``, ``, ``, ``, ``…``, +//! and `` for the `<` key itself +//! - with Ctrl or Alt: ``, `` (or ``), `` +//! - ``: the leader key, `` unless keys.toml says otherwise +//! +//! Names in angle brackets ignore case: `` is ``. + +use std::fmt; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use thiserror::Error; + +/// A key on the keyboard, without Ctrl or Alt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum KeyName { + /// A key that types a character. Shift is part of the character: `G` + /// is Shift+g. + Char(char), + Enter, + Esc, + Tab, + /// Shift+Tab. + BackTab, + Backspace, + Delete, + Insert, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + /// A function key, `F1` to `F12`. + F(u8), +} + +/// One key press: a key, and whether Ctrl or Alt was held. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Key { + pub name: KeyName, + pub ctrl: bool, + pub alt: bool, +} + +impl Key { + /// A key pressed on its own. + pub const fn plain(name: KeyName) -> Key { + Key { name, ctrl: false, alt: false } + } + + /// A character key pressed on its own. + pub const fn char(c: char) -> Key { + Key::plain(KeyName::Char(c)) + } + + /// The key from a terminal event, or `None` for keys cumulus doesn't use + /// (media keys, a modifier pressed alone…). + pub fn from_event(event: KeyEvent) -> Option { + let ctrl = event.modifiers.contains(KeyModifiers::CONTROL); + let alt = event.modifiers.contains(KeyModifiers::ALT); + let name = match event.code { + // Ctrl+Shift+a and Ctrl+a are the same key for cumulus. + KeyCode::Char(c) if ctrl => KeyName::Char(c.to_ascii_lowercase()), + KeyCode::Char(c) => KeyName::Char(c), + KeyCode::Enter => KeyName::Enter, + KeyCode::Esc => KeyName::Esc, + KeyCode::Tab => KeyName::Tab, + KeyCode::BackTab => KeyName::BackTab, + KeyCode::Backspace => KeyName::Backspace, + KeyCode::Delete => KeyName::Delete, + KeyCode::Insert => KeyName::Insert, + KeyCode::Up => KeyName::Up, + KeyCode::Down => KeyName::Down, + KeyCode::Left => KeyName::Left, + KeyCode::Right => KeyName::Right, + KeyCode::Home => KeyName::Home, + KeyCode::End => KeyName::End, + KeyCode::PageUp => KeyName::PageUp, + KeyCode::PageDown => KeyName::PageDown, + KeyCode::F(number) => KeyName::F(number), + _ => return None, + }; + Some(Key { name, ctrl, alt }) + } + + /// The character a plain character key types. + pub fn as_char(self) -> Option { + match self { + Key { name: KeyName::Char(c), ctrl: false, alt: false } => Some(c), + _ => None, + } + } + + /// Whether this key cancels, like Esc. Vim treats Ctrl-c and Ctrl-[ the + /// same way. + pub fn is_escape(self) -> bool { + matches!( + self, + Key { name: KeyName::Esc, ctrl: false, alt: false } + | Key { name: KeyName::Char('c' | '['), ctrl: true, alt: false } + ) + } +} + +impl fmt::Display for Key { + /// The key in vim notation, as which-key and the help show it. The `<` + /// key shows as itself, so `<<` reads naturally. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self.name { + KeyName::Char(' ') => "Space".to_string(), + KeyName::Char(c) if !self.ctrl && !self.alt => return write!(f, "{c}"), + KeyName::Char(c) => c.to_string(), + KeyName::Enter => "CR".into(), + KeyName::Esc => "Esc".into(), + KeyName::Tab => "Tab".into(), + KeyName::BackTab => "S-Tab".into(), + KeyName::Backspace => "BS".into(), + KeyName::Delete => "Del".into(), + KeyName::Insert => "Ins".into(), + KeyName::Up => "Up".into(), + KeyName::Down => "Down".into(), + KeyName::Left => "Left".into(), + KeyName::Right => "Right".into(), + KeyName::Home => "Home".into(), + KeyName::End => "End".into(), + KeyName::PageUp => "PageUp".into(), + KeyName::PageDown => "PageDown".into(), + KeyName::F(number) => format!("F{number}"), + }; + let ctrl = if self.ctrl { "C-" } else { "" }; + let alt = if self.alt { "A-" } else { "" }; + write!(f, "<{ctrl}{alt}{name}>") + } +} + +/// Keys written back in notation: `v`. +pub fn notation(keys: &[Key]) -> String { + keys.iter().map(ToString::to_string).collect() +} + +/// Why keys written in notation couldn't be read. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum NotationError { + #[error("no keys")] + Empty, + #[error("there's no key called <{0}>")] + UnknownKey(String), +} + +/// Reads keys written in vim notation. `leader` is the key `` +/// stands for. +pub fn parse_keys(text: &str, leader: Key) -> Result, NotationError> { + let mut keys = Vec::new(); + let mut rest = text; + while let Some(c) = rest.chars().next() { + // `<` starts a key name when one follows (``); otherwise it's the + // `<` key itself, as in `<<`. + if c == '<' + && let Some(end) = rest.find('>') + && looks_like_a_name(&rest[1..end]) + { + keys.push(named_key(&rest[1..end], leader)?); + rest = &rest[end + 1..]; + } else { + keys.push(Key::char(c)); + rest = &rest[c.len_utf8()..]; + } + } + if keys.is_empty() { Err(NotationError::Empty) } else { Ok(keys) } +} + +/// Whether the text between `<` and `>` is meant as a key name, so a typo +/// like `` is reported instead of read as six keys. +fn looks_like_a_name(text: &str) -> bool { + !text.is_empty() && text.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +/// A key from its name in angle brackets, like `C-w` or `Space`. +fn named_key(name: &str, leader: Key) -> Result { + if name.eq_ignore_ascii_case("leader") { + return Ok(leader); + } + let (mut ctrl, mut alt) = (false, false); + let mut base = name; + while base.len() > 2 && base.as_bytes()[1] == b'-' { + match base.as_bytes()[0].to_ascii_lowercase() { + b'c' => ctrl = true, + b'a' | b'm' => alt = true, + _ => break, + } + base = &base[2..]; + } + let unknown = || NotationError::UnknownKey(name.to_string()); + let lower = base.to_ascii_lowercase(); + let key_name = match lower.as_str() { + "cr" | "enter" | "return" => KeyName::Enter, + "esc" => KeyName::Esc, + "tab" => KeyName::Tab, + "s-tab" => KeyName::BackTab, + "bs" | "backspace" => KeyName::Backspace, + "del" | "delete" => KeyName::Delete, + "ins" | "insert" => KeyName::Insert, + "space" => KeyName::Char(' '), + "lt" => KeyName::Char('<'), + "bar" => KeyName::Char('|'), + "bslash" => KeyName::Char('\\'), + "up" => KeyName::Up, + "down" => KeyName::Down, + "left" => KeyName::Left, + "right" => KeyName::Right, + "home" => KeyName::Home, + "end" => KeyName::End, + "pageup" => KeyName::PageUp, + "pagedown" => KeyName::PageDown, + _ => { + let mut chars = base.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if ctrl => KeyName::Char(c.to_ascii_lowercase()), + (Some(c), None) => KeyName::Char(c), + _ => match lower.strip_prefix('f').and_then(|number| number.parse::().ok()) { + Some(number @ 1..=12) => KeyName::F(number), + _ => return Err(unknown()), + }, + } + } + }; + Ok(Key { name: key_name, ctrl, alt }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SPACE: Key = Key::char(' '); + + fn keys(text: &str) -> Vec { + parse_keys(text, SPACE).unwrap() + } + + #[test] + fn notation_reads_and_writes_back() { + for text in + ["gg", "v", "c", "", "", "ZZ", "<<", "", "", "", "]p"] + { + assert_eq!(notation(&keys(text)), text, "{text}"); + } + assert_eq!(keys(""), keys("<")); + assert_eq!(keys(""), keys(""), "names ignore case"); + assert_eq!(keys(""), keys(""), "Ctrl letters are lowercase"); + assert_eq!(keys(""), keys("")); + assert_eq!(keys("w"), keys("w")); + } + + #[test] + fn typos_are_reported() { + assert_eq!(parse_keys("", SPACE), Err(NotationError::UnknownKey("Spcae".into()))); + assert_eq!(parse_keys("", SPACE), Err(NotationError::UnknownKey("F13".into()))); + assert_eq!(parse_keys("", SPACE), Err(NotationError::Empty)); + assert_eq!(keys("< >").len(), 3, "not a name: three keys"); + } + + #[test] + fn terminal_events_become_keys() { + let event = |code, modifiers| Key::from_event(KeyEvent::new(code, modifiers)); + assert_eq!( + event(KeyCode::Char('W'), KeyModifiers::CONTROL | KeyModifiers::SHIFT), + Some(keys("")[0]) + ); + assert_eq!(event(KeyCode::Char('G'), KeyModifiers::SHIFT), Some(Key::char('G'))); + assert!(event(KeyCode::Esc, KeyModifiers::NONE).is_some_and(Key::is_escape)); + assert!(event(KeyCode::Char('c'), KeyModifiers::CONTROL).is_some_and(Key::is_escape)); + } +} diff --git a/src/input/keymap.rs b/src/input/keymap.rs new file mode 100644 index 0000000..dc8da49 --- /dev/null +++ b/src/input/keymap.rs @@ -0,0 +1,334 @@ +//! The keys in effect: the default tables from [`bindings`](super::bindings), +//! with your changes from `keys.toml`: +//! +//! ```toml +//! leader = "" # the key stands for +//! +//! [normal] # a section per place: motions, normal, canvas, +//! "x" = "delete-branch" # outline, note, visual, move, connect, list +//! "Q" = "close" +//! "x" = "nothing" # "nothing" removes a default key +//! ``` +//! +//! Mistakes don't stop the rest of the file from working: each one is skipped +//! and reported in words. + +use std::collections::BTreeMap; + +use super::bindings::{Context, GROUPS, NODE_TARGETS, SITUATIONS, TARGET_GROUPS}; +use super::command::{self, Bound, COMMANDS, TargetBound}; +use super::key::{Key, notation, parse_keys}; + +/// The leader key unless `keys.toml` names another. +pub const DEFAULT_LEADER: Key = Key::char(' '); + +/// Keys bound to something, and what that does in words. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Binding { + pub keys: Vec, + pub bound: Bound, + pub help: String, +} + +/// Keys typed after an operator, and what they make it act on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetBinding { + pub keys: Vec, + pub bound: TargetBound, + pub help: String, +} + +/// Which keys do what, everywhere. +#[derive(Debug, Clone)] +pub struct Keymap { + leader: Key, + tables: BTreeMap>, + targets: Vec, + groups: Vec<(Vec, &'static str)>, + target_groups: Vec<(Vec, &'static str)>, +} + +/// The tables in effect at one moment, most specific first, and what an +/// operator can act on there. +#[derive(Debug, Clone, Copy)] +pub struct Tables<'a> { + keymap: &'a Keymap, + contexts: &'a [Context], +} + +impl Keymap { + /// The default keys, with `leader` as the leader key. + pub fn new(leader: Key) -> Keymap { + let parse = |text: &str| parse_keys(text, leader).ok(); + let tables = Context::ALL + .into_iter() + .map(|context| { + let bindings = context + .defaults() + .iter() + .filter_map(|default| { + let help = default.help.unwrap_or_else(|| command::help_for(default.bound)); + Some(Binding { + keys: parse(default.keys)?, + bound: default.bound, + help: help.to_string(), + }) + }) + .collect(); + (context, bindings) + }) + .collect(); + let targets = NODE_TARGETS + .iter() + .filter_map(|(keys, bound, help)| { + Some(TargetBinding { keys: parse(keys)?, bound: *bound, help: help.to_string() }) + }) + .collect(); + let named = |groups: &[(&str, &'static str)]| -> Vec<(Vec, &'static str)> { + groups.iter().filter_map(|(keys, name)| Some((parse(keys)?, *name))).collect() + }; + Keymap { leader, tables, targets, groups: named(GROUPS), target_groups: named(TARGET_GROUPS) } + } + + /// The default keys, changed by the text of a `keys.toml` file. Returns + /// the keymap and the problems found, in words. A problem only drops the + /// line it's on. + pub fn from_toml(text: &str) -> (Keymap, Vec) { + let mut problems = Vec::new(); + let file: toml::Table = match toml::from_str(text) { + Ok(file) => file, + Err(error) => { + return (Keymap::new(DEFAULT_LEADER), vec![format!("keys.toml isn't valid TOML: {error}")]); + } + }; + // The leader comes first: every `` key depends on it. + let leader = match file.get("leader") { + None => DEFAULT_LEADER, + Some(toml::Value::String(text)) => match parse_keys(text, DEFAULT_LEADER).as_deref() { + Ok([key]) => *key, + Ok(_) => { + problems.push(format!("leader = {text:?}: the leader is one key")); + DEFAULT_LEADER + } + Err(error) => { + problems.push(format!("leader = {text:?}: {error}")); + DEFAULT_LEADER + } + }, + Some(_) => { + problems.push("leader should be a key in quotes, like \"\"".into()); + DEFAULT_LEADER + } + }; + let mut keymap = Keymap::new(leader); + for (section, value) in &file { + if section == "leader" { + continue; + } + let Some(context) = Context::named(section) else { + let sections: Vec<&str> = Context::ALL.iter().map(|context| context.name()).collect(); + problems.push(format!("[{section}] isn't a section: use one of {}", sections.join(", "))); + continue; + }; + let Some(entries) = value.as_table() else { + problems.push(format!("{section} should be a section, [{section}], with keys in it")); + continue; + }; + for (keys_text, name) in entries { + if let Err(problem) = keymap.apply(context, keys_text, name) { + problems.push(format!("[{section}] {keys_text:?}: {problem}")); + } + } + } + problems.extend(keymap.hidden_bindings()); + (keymap, problems) + } + + /// One line of a `keys.toml` section: bind `keys_text` to the command + /// named by `name`, or unbind it for "nothing". + fn apply(&mut self, context: Context, keys_text: &str, name: &toml::Value) -> Result<(), String> { + let keys = parse_keys(keys_text, self.leader).map_err(|error| error.to_string())?; + if let Some(first) = keys[0].as_char() { + if first.is_ascii_digit() && first != '0' { + return Err("keys can't start with 1–9: those type a count".into()); + } + if first == '"' { + return Err("keys can't start with \": it names a register".into()); + } + } + let Some(name) = name.as_str() else { + return Err("the command should be a name in quotes, like \"delete-branch\"".into()); + }; + let table = self.tables.entry(context).or_default(); + table.retain(|binding| binding.keys != keys); + if name == "nothing" { + return Ok(()); + } + let Some(info) = command::by_name(name) else { + return Err(match suggestion(name) { + Some(close) => format!("there's no command {name:?}; did you mean {close:?}?"), + None => format!("there's no command {name:?}"), + }); + }; + table.push(Binding { keys, bound: info.bound, help: info.help.to_string() }); + Ok(()) + } + + /// Bindings that can never be typed because a shorter one starts the + /// same way (with `x` bound, `xy` can't be reached), in words. + fn hidden_bindings(&self) -> Vec { + let mut problems = Vec::new(); + for (situation, contexts) in SITUATIONS { + let bindings = self.tables(contexts).bindings(); + for short in &bindings { + for long in &bindings { + if long.keys.len() > short.keys.len() && long.keys.starts_with(&short.keys) { + problems.push(format!( + "in {situation}, {} hides {}: typing {} runs \"{}\" first", + notation(&short.keys), + notation(&long.keys), + notation(&short.keys), + short.help + )); + } + } + } + } + problems + } + + /// The leader key. + pub fn leader(&self) -> Key { + self.leader + } + + /// The tables for some contexts, most specific first (see + /// [`SITUATIONS`]). + pub fn tables<'a>(&'a self, contexts: &'a [Context]) -> Tables<'a> { + Tables { keymap: self, contexts } + } +} + +impl<'a> Tables<'a> { + /// Every binding in effect, in order. Where two tables bind the same + /// keys, only the first one's binding is here. + pub fn bindings(&self) -> Vec<&'a Binding> { + let mut found: Vec<&'a Binding> = Vec::new(); + for context in self.contexts { + for binding in self.keymap.tables.get(context).into_iter().flatten() { + if found.iter().all(|other| other.keys != binding.keys) { + found.push(binding); + } + } + } + found + } + + /// The bindings whose keys start with `keys`. + pub fn commands_starting_with(&self, keys: &[Key]) -> Vec<&'a Binding> { + self.bindings().into_iter().filter(|binding| binding.keys.starts_with(keys)).collect() + } + + /// The targets whose keys start with `keys`. + pub fn targets_starting_with(&self, keys: &[Key]) -> Vec<&'a TargetBinding> { + self.keymap.targets.iter().filter(|target| target.keys.starts_with(keys)).collect() + } + + /// The name of a group of keys, like "go to" for `g`. + pub fn group_name(&self, keys: &[Key]) -> Option<&'static str> { + self.keymap.groups.iter().find(|(group, _)| group == keys).map(|(_, name)| *name) + } + + /// The name of a group of targets, like "around" for `a`. + pub fn target_group_name(&self, keys: &[Key]) -> Option<&'static str> { + self.keymap.target_groups.iter().find(|(group, _)| group == keys).map(|(_, name)| *name) + } +} + +/// A command name close to a mistyped one, to suggest it. +fn suggestion(name: &str) -> Option<&'static str> { + let shared = |other: &str| name.chars().zip(other.chars()).take_while(|(a, b)| a == b).count(); + COMMANDS + .iter() + .map(|info| info.name) + .filter(|other| other.contains(name) || shared(other) >= 3) + .max_by_key(|other| shared(other)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::command::{Command, Motion}; + + fn keys(text: &str) -> Vec { + parse_keys(text, DEFAULT_LEADER).unwrap() + } + + fn bound_to(keymap: &Keymap, contexts: &[Context], text: &str) -> Option { + let tables = keymap.tables(contexts); + tables.bindings().into_iter().find(|binding| binding.keys == keys(text)).map(|binding| binding.bound) + } + + #[test] + fn every_default_key_parses_and_none_hides_another() { + let keymap = Keymap::new(DEFAULT_LEADER); + for context in Context::ALL { + assert_eq!(keymap.tables[&context].len(), context.defaults().len(), "{context:?}"); + } + assert_eq!(keymap.targets.len(), NODE_TARGETS.len()); + assert_eq!(keymap.hidden_bindings(), Vec::::new()); + } + + #[test] + fn keys_toml_changes_and_removes_keys() { + let (keymap, problems) = Keymap::from_toml( + r#" + leader = "," + [normal] + "Q" = "close" + "x" = "nothing" + "h" = "help" + "#, + ); + assert_eq!(problems, Vec::::new()); + let normal = &[Context::Canvas, Context::Normal, Context::Motions]; + assert_eq!(bound_to(&keymap, normal, "Q"), Some(Bound::Command(Command::Close))); + assert_eq!(bound_to(&keymap, normal, "x"), None); + assert_eq!(bound_to(&keymap, normal, ",h"), Some(Bound::Command(Command::Help))); + assert_eq!(bound_to(&keymap, normal, ",w"), Some(Bound::Command(Command::ToggleWhichKey))); + assert_eq!(bound_to(&keymap, normal, "h"), Some(Bound::Command(Command::Move(Motion::Left)))); + } + + #[test] + fn mistakes_are_reported_and_skipped() { + let (keymap, problems) = Keymap::from_toml( + r#" + [normal] + "" = "help" + "X" = "split-vert" + "5x" = "help" + "Y" = 3 + "g" = "help" + [nromal] + "#, + ); + // TOML hands sections and keys over in alphabetical order. + assert_eq!( + problems[..5], + [ + "[normal] \"5x\": keys can't start with 1–9: those type a count", + "[normal] \"\": there's no key called ", + "[normal] \"X\": there's no command \"split-vert\"; did you mean \"split-vertical\"?", + "[normal] \"Y\": the command should be a name in quotes, like \"delete-branch\"", + "[nromal] isn't a section: use one of motions, normal, canvas, outline, note, visual, move, connect, list", + ] + ); + let hidden = "in normal mode on the canvas, g hides gt: typing g runs \"help: every key\" first"; + assert!(problems.iter().any(|problem| problem == hidden), "{problems:#?}"); + assert_eq!( + bound_to(&keymap, &[Context::Normal], "u"), + Some(Bound::Command(Command::Undo)), + "the rest works" + ); + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..8feee8c --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,16 @@ +//! Keys and the mouse: turning what you press into commands. +//! +//! - [`key`]: one key press, and vim's notation for writing keys (`v`). +//! - [`command`]: everything a key can ask for, with names for `keys.toml`. +//! - [`bindings`]: the default keys, in tables per mode and view. +//! - [`keymap`]: those tables with your changes from `keys.toml`. +//! - [`engine`]: reads presses the way vim does (counts, registers, `d` + +//! `ab`) and says what can follow, for which-key. +//! - [`mouse`]: presses, double-clicks, drags and the wheel. + +pub mod bindings; +pub mod command; +pub mod engine; +pub mod key; +pub mod keymap; +pub mod mouse; diff --git a/src/input/mouse.rs b/src/input/mouse.rs new file mode 100644 index 0000000..f55b653 --- /dev/null +++ b/src/input/mouse.rs @@ -0,0 +1,119 @@ +//! The mouse: presses, double-clicks, drags and the wheel, read from the +//! terminal's raw button events. + +use std::time::{Duration, Instant}; + +use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + +use crate::model::Direction; + +/// Two left presses this close in time, on the same cell, are a double-click. +const DOUBLE_CLICK: Duration = Duration::from_millis(400); + +/// A cell on the terminal screen, counted from its top-left corner. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct Cell { + pub col: u16, + pub row: u16, +} + +/// What the mouse did, in terms the views care about. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Gesture { + /// The left button went down: select what's there. `ctrl`: add to the + /// selection instead. + Press { at: Cell, ctrl: bool }, + /// A second left press on the same cell, soon after the first. + DoublePress { at: Cell }, + /// The right button went down: the menu. + RightPress { at: Cell }, + /// The mouse moved with the left button down, since it was pressed at + /// `from`. + Drag { from: Cell, at: Cell, ctrl: bool }, + /// The left button came up; `dragged` tells whether it moved first. + Release { at: Cell, dragged: bool }, + /// The wheel turned (sideways on a touchpad). + Scroll { at: Cell, direction: Direction }, +} + +/// Turns raw mouse events into gestures. It remembers the last press, to +/// spot double-clicks and drags. +#[derive(Debug, Default)] +pub struct MouseReader { + last_press: Option<(Instant, Cell)>, + pressed_at: Option, + dragged: bool, +} + +impl MouseReader { + /// Reads one event. `now` is when it arrived. + pub fn read(&mut self, event: MouseEvent, now: Instant) -> Option { + let at = Cell { col: event.column, row: event.row }; + let ctrl = event.modifiers.contains(KeyModifiers::CONTROL); + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + let double = self + .last_press + .is_some_and(|(time, cell)| cell == at && now.duration_since(time) <= DOUBLE_CLICK); + self.pressed_at = Some(at); + self.dragged = false; + if double { + // A third press starts over rather than making another double. + self.last_press = None; + Some(Gesture::DoublePress { at }) + } else { + self.last_press = Some((now, at)); + Some(Gesture::Press { at, ctrl }) + } + } + MouseEventKind::Down(MouseButton::Right) => Some(Gesture::RightPress { at }), + MouseEventKind::Drag(MouseButton::Left) => { + let from = self.pressed_at?; + self.dragged = true; + Some(Gesture::Drag { from, at, ctrl }) + } + MouseEventKind::Up(MouseButton::Left) => { + self.pressed_at.take()?; + Some(Gesture::Release { at, dragged: std::mem::take(&mut self.dragged) }) + } + MouseEventKind::ScrollUp => Some(Gesture::Scroll { at, direction: Direction::Up }), + MouseEventKind::ScrollDown => Some(Gesture::Scroll { at, direction: Direction::Down }), + MouseEventKind::ScrollLeft => Some(Gesture::Scroll { at, direction: Direction::Left }), + MouseEventKind::ScrollRight => Some(Gesture::Scroll { at, direction: Direction::Right }), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent { + MouseEvent { kind, column: col, row, modifiers: KeyModifiers::NONE } + } + + #[test] + fn presses_doubles_and_drags() { + let mut mouse = MouseReader::default(); + let start = Instant::now(); + let at = |ms| start + Duration::from_millis(ms); + let down = MouseEventKind::Down(MouseButton::Left); + let up = MouseEventKind::Up(MouseButton::Left); + let cell = Cell { col: 3, row: 4 }; + + assert_eq!(mouse.read(event(down, 3, 4), at(0)), Some(Gesture::Press { at: cell, ctrl: false })); + assert_eq!(mouse.read(event(up, 3, 4), at(50)), Some(Gesture::Release { at: cell, dragged: false })); + assert_eq!(mouse.read(event(down, 3, 4), at(200)), Some(Gesture::DoublePress { at: cell })); + mouse.read(event(up, 3, 4), at(250)); + assert!(matches!(mouse.read(event(down, 3, 4), at(1000)), Some(Gesture::Press { .. })), "too late"); + + let drag = MouseEventKind::Drag(MouseButton::Left); + let to = Cell { col: 9, row: 4 }; + assert_eq!( + mouse.read(event(drag, 9, 4), at(1100)), + Some(Gesture::Drag { from: cell, at: to, ctrl: false }) + ); + assert_eq!(mouse.read(event(up, 9, 4), at(1200)), Some(Gesture::Release { at: to, dragged: true })); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2fc8627..46d6959 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,10 +7,12 @@ //! | Module | What's in it | //! |-------------|--------------------------------------------------------------| //! | [`model`] | the data and its rules: documents, nodes, links, undo, folds | +//! | [`input`] | keys and the mouse: vim keys, which-key, keys.toml | //! | [`storage`] | files: documents (RON) | //! | [`view`] | the views, one module each: so far, the canvas | -//! | [`ui`] | what views share: glyphs, the theme | +//! | [`ui`] | what views share: glyphs, the theme, the which-key popup | +pub mod input; pub mod model; pub mod storage; pub mod ui; diff --git a/src/model/geometry.rs b/src/model/geometry.rs index 17f2552..bd5b2d6 100644 --- a/src/model/geometry.rs +++ b/src/model/geometry.rs @@ -128,3 +128,25 @@ impl CanvasRect { && other.top() < self.bottom() } } + +/// The four directions, on screen and on the canvas. Up is toward smaller +/// `y`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Direction { + Up, + Right, + Down, + Left, +} + +impl Direction { + /// The direction pointing the other way. + pub fn opposite(self) -> Direction { + match self { + Direction::Up => Direction::Down, + Direction::Right => Direction::Left, + Direction::Down => Direction::Up, + Direction::Left => Direction::Right, + } + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 42cbdbf..494e70d 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -25,7 +25,7 @@ mod text; 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}; +pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction}; pub use history::{History, StateId}; pub use ids::{DocUid, NodeId}; pub use link::{Link, LinkKey}; diff --git a/src/ui/glyphs.rs b/src/ui/glyphs.rs index f0b3e75..e5689cd 100644 --- a/src/ui/glyphs.rs +++ b/src/ui/glyphs.rs @@ -4,7 +4,7 @@ use std::sync::LazyLock; -use crate::model::Stroke; +use crate::model::{Direction, Stroke}; /// How heavy one arm of a line is, in one cell. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -26,27 +26,6 @@ impl Weight { } } -/// The four directions on screen. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Direction { - Up, - Right, - Down, - Left, -} - -impl Direction { - /// The direction pointing the other way. - pub fn opposite(self) -> Direction { - match self { - Direction::Up => Direction::Down, - Direction::Right => Direction::Left, - Direction::Down => Direction::Up, - Direction::Left => Direction::Right, - } - } -} - /// The arms of the lines that meet in one cell. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] pub struct Arms { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 87e86c4..040b1e9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,7 @@ -//! What the views share: the characters they draw with ([`glyphs`]) and the -//! colors and looks ([`theme`]). The which-key popup, the command line, the -//! bars and the other shared widgets come in later stages. +//! What the views share: the characters they draw with ([`glyphs`]), the +//! colors and looks ([`theme`]) and the which-key popup ([`which_key`]). The +//! command line, the bars and the other shared widgets come in later stages. pub mod glyphs; pub mod theme; +pub mod which_key; diff --git a/src/ui/which_key.rs b/src/ui/which_key.rs new file mode 100644 index 0000000..e0a2763 --- /dev/null +++ b/src/ui/which_key.rs @@ -0,0 +1,98 @@ +//! The which-key popup: after the first key of a sequence (`g`, `z`, the +//! leader…), it lists the keys that can follow, the way skyshift does. +//! +//! ```text +//! ╭ g · go to ───────────────────────╮ +//! │ h the parent g the root │ +//! │ l the first child f through … │ +//! ╰──────────────────────────────────╯ +//! ``` + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Widget}; +use unicode_width::UnicodeWidthStr; + +use super::theme::Theme; +use crate::input::engine::Hints; + +/// The popup, for some hints. +pub struct WhichKey<'a> { + pub hints: &'a Hints, + pub theme: &'a Theme, +} + +impl Widget for WhichKey<'_> { + /// Draws in the bottom-right corner of `area`, two columns and a row in + /// from its edges. Long lists spread over several columns. + fn render(self, area: Rect, buf: &mut Buffer) { + let hints = &self.hints.entries; + if hints.is_empty() || area.width < 8 || area.height < 3 { + return; + } + let key_width = hints.iter().map(|hint| hint.keys.width()).max().unwrap_or(1); + let help_width = hints.iter().map(|hint| hint.help.width()).max().unwrap_or(1); + // " key help " plus a space between columns. + let entry_width = key_width + help_width + 3; + let max_rows = usize::from(area.height.saturating_sub(4)).max(1); + let columns = hints.len().div_ceil(max_rows).max(1); + let rows = hints.len().div_ceil(columns); + let width = u16::try_from(entry_width * columns + 2).unwrap_or(u16::MAX).min(area.width); + let height = u16::try_from(rows + 2).unwrap_or(u16::MAX).min(area.height); + let rect = Rect { + x: area.right().saturating_sub(width + 2).max(area.x), + y: area.bottom().saturating_sub(height + 1).max(area.y), + width, + height, + }; + + let key_style = Style::new().fg(self.theme.accent).add_modifier(Modifier::BOLD); + let lines: Vec = (0..rows) + .map(|row| { + let mut spans = Vec::new(); + for column in 0..columns { + let Some(hint) = hints.get(column * rows + row) else { continue }; + spans.push(Span::styled(format!(" {:>key_width$} ", hint.keys), key_style)); + let color = if hint.group { self.theme.accent } else { Color::Reset }; + spans.push(Span::styled(format!("{:(); + assert_eq!(row(3), " ╭ g · go to ───╮ "); + assert_eq!(row(4), " │ h the parent │ "); + assert_eq!(row(5), " │ g the root │ "); + assert_eq!(row(6), " ╰──────────────╯ "); + } +} diff --git a/src/view/canvas/lines.rs b/src/view/canvas/lines.rs index 51fb106..16a7859 100644 --- a/src/view/canvas/lines.rs +++ b/src/view/canvas/lines.rs @@ -6,8 +6,9 @@ use ratatui::layout::Size; use ratatui::style::Color; use super::camera::{ScreenPos, ScreenRect}; +use crate::model::Direction; use crate::model::{Border, LinkKey, NodeId, Stroke}; -use crate::ui::glyphs::{Arms, Dash, Direction, Glyphs, Weight}; +use crate::ui::glyphs::{Arms, Dash, Glyphs, Weight}; /// What a line cell belongs to, so a click on a line knows what it hit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/view/canvas/navigate.rs b/src/view/canvas/navigate.rs index 70ee6d2..c238312 100644 --- a/src/view/canvas/navigate.rs +++ b/src/view/canvas/navigate.rs @@ -1,7 +1,7 @@ //! Moving between nodes with hjkl: the nearest node in a direction. +use crate::model::Direction; use crate::model::{CanvasRect, NodeId}; -use crate::ui::glyphs::Direction; /// The node to go to from `from`, heading `direction`: the nearest one whose /// middle lies that way. Nodes within 45° of straight ahead come first, and diff --git a/src/view/canvas/route.rs b/src/view/canvas/route.rs index aa2e264..51f77b8 100644 --- a/src/view/canvas/route.rs +++ b/src/view/canvas/route.rs @@ -10,7 +10,7 @@ use super::camera::{ScreenPos, ScreenRect}; use super::lines::{direction_between, step}; -use crate::ui::glyphs::Direction; +use crate::model::Direction; /// Where a line that has to change rows (or columns) bends. #[derive(Debug, Clone, Copy, PartialEq, Eq)]