From 57ba2c275d3f83b1bc59adb6e96db41ee089004e Mon Sep 17 00:00:00 2001 From: Skkeye Date: Thu, 1 Oct 2026 04:17:41 -0400 Subject: [PATCH] Make marks and counts work, and the completion popup a setting Marks: the engine read `ma` and `'a`, but the canvas example only said they'd come later. It now sets and jumps to marks, and `''` goes back to where the cursor was before the last jump (a mark, gg, G or :find). Which-key lists `'` after `'`. Counts on operators now mean something on nodes: `3dd` deletes this node and the next two siblings, with their branches, the way vim's `3dd` deletes three lines (Document::siblings_from). Counts before the operator and before the target still multiply, and which-key now says what they add up to: after `2d3`, the `d` entry reads "6 nodes: this one and the next 5 siblings, with their branches". The completion popup can now wait for Tab, like vim's wildmenu: the `completion_popup` setting is "as-you-type" (the default) or "on-tab". The config file comes with the app core; until then the example takes `:set completion_popup=on-tab`. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 4 ++ examples/canvas.rs | 105 ++++++++++++++++++++++++++++++++++++----- src/input/command.rs | 4 +- src/input/engine.rs | 19 +++++++- src/model/document.rs | 11 +++++ src/ui/command_line.rs | 91 +++++++++++++++++++++++++++-------- 6 files changed, 199 insertions(+), 35 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 29ff234..34dd047 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -87,6 +87,9 @@ lines. - Tab and `Ctrl-i` jump forward, as in vim, so `a` adds a child. - `dd` deletes a node with its branch; `x` deletes just the node, and its children move up. +- A count on `dd`, `yy`, `>>` or `<<` takes in the next siblings: `3dd` + deletes this node and the next two, with their branches, as vim's `3dd` + takes three lines. Which-key says how many as you type (`2d3`: 6 nodes). - Rapid entry is what happens after `o`, `O` or `a`: Enter starts the next sibling, Tab / Shift-Tab indent and outdent it, Enter on an empty line stops. Esc switches to vim motions inside the text; a second Esc leaves. @@ -334,6 +337,7 @@ which_key = true which_key_delay = "300ms" max_node_width = 30 glyphs = "unicode" # "ascii" for fonts without box-drawing characters +completion_popup = "as-you-type" # "on-tab": only after Tab, like vim's wildmenu # keys.toml: sections motions, normal, canvas, outline, note, visual, move, # connect, list; commands by name (src/input/command.rs); "nothing" unbinds diff --git a/examples/canvas.rs b/examples/canvas.rs index c8b17e4..39606b5 100644 --- a/examples/canvas.rs +++ b/examples/canvas.rs @@ -22,11 +22,12 @@ //! Mouse: click selects, dragging a node moves its branch, dragging empty //! space pans, the wheel scrolls. +use std::collections::BTreeMap; use std::io; use std::time::{Duration, Instant}; use cumulus::command::history::History as CommandHistory; -use cumulus::command::parse::{Call, Sources, TagChange, parse}; +use cumulus::command::parse::{Call, SetArg, Sources, TagChange, parse}; use cumulus::input::bindings::Context; use cumulus::input::command::{Bound, Command, Motion, Operator, Target, help_for}; use cumulus::input::engine::{KeyEngine, Outcome, Typed}; @@ -38,7 +39,7 @@ use cumulus::model::{ History, Link, LinkKey, Node, NodeId, Spot, Title, Visible, }; use cumulus::storage::document_file; -use cumulus::ui::command_line::{CommandLine, LineOutcome}; +use cumulus::ui::command_line::{CommandLine, LineOutcome, PopupMode}; use cumulus::ui::theme::Theme; use cumulus::ui::which_key::WhichKey; use cumulus::view::canvas::boxes::{NodeBox, box_rects}; @@ -118,6 +119,12 @@ struct Playground { /// The command line, while it's open. command_line: Option, command_history: CommandHistory, + /// When the command line's popup shows (`:set completion_popup=on-tab`). + popup: PopupMode, + /// Marked nodes (`ma`), and where the cursor was before the last jump + /// (for `''`). + marks: BTreeMap, + before_jump: Option, /// The last frame drawn, to match clicks against. scene: Scene, canvas_area: Rect, @@ -144,6 +151,9 @@ impl Playground { drag: None, command_line: None, command_history: CommandHistory::default(), + popup: PopupMode::default(), + marks: BTreeMap::new(), + before_jump: None, scene: Scene::default(), canvas_area: Rect::default(), follow: true, @@ -284,7 +294,24 @@ impl Playground { 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::Current | Target::Branch) => { + self.delete_branches(times) + } + Command::SetMark(name) => { + self.marks.insert(name, self.cursor); + let title = + self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default(); + self.message = format!("mark {name} is on \"{title}\" ('{name} comes back here)"); + } + Command::JumpToMark('\'' | '`') => match self.before_jump { + Some(id) if self.doc.contains(id) => self.leap(Some(id)), + _ => self.message = "no jump to go back from yet".into(), + }, + Command::JumpToMark(name) => match self.marks.get(&name) { + Some(&id) if self.doc.contains(id) => self.leap(Some(id)), + Some(_) => self.message = format!("the node marked {name} is gone"), + None => self.message = format!("no mark {name} yet: m{name} sets it"), + }, Command::Operate(Operator::Delete, Target::Children) => self.delete_children(), Command::DeleteNode => self.delete_node(), Command::AddChild => self.add(Spot::LastChildOf(self.cursor)), @@ -313,7 +340,8 @@ impl Playground { Command::HalfPageUp => self.camera.pan(0, -i32::from(self.canvas_area.height / 2)), Command::MoveMode => self.mode = Mode::Move { whole: true, change: None }, Command::CommandLine => { - self.command_line = Some(CommandLine::new(&self.sources(), &self.command_history)); + self.command_line = + Some(CommandLine::new(&self.sources(), &self.command_history, self.popup)); } Command::ToggleWhichKey => { self.which_key = !self.which_key; @@ -355,7 +383,12 @@ impl Playground { return; } }; - self.jump(target); + // gg and G are jumps, as in vim: '' comes back from them. + if matches!(motion, Motion::First | Motion::Last) { + self.leap(target); + } else { + self.jump(target); + } } /// Commands in move mode: hjkl moves the node or its branch. @@ -447,7 +480,7 @@ impl Playground { colors: self.theme.palette.keys().map(ToString::to_string).collect(), styles: self.theme.node_styles.keys().map(ToString::to_string).collect(), themes: vec!["default".into()], - settings: Vec::new(), + settings: vec!["completion_popup".into()], tags, folder: std::env::current_dir().unwrap_or_default(), } @@ -507,6 +540,26 @@ impl Playground { } Call::Find(text) => self.find(&text), Call::Version => self.message = cumulus::version(), + Call::Set(SetArg::Value(name, value)) if name == "completion_popup" => { + match PopupMode::named(&value) { + Some(popup) => { + self.popup = popup; + self.message = format!("completion_popup = {value}"); + } + None => { + let values: Vec<&str> = PopupMode::VALUES.iter().map(|(name, _)| *name).collect(); + self.message = format!("completion_popup can be {}", values.join(" or ")); + } + } + } + Call::Set(SetArg::Reset(name)) if name == "completion_popup" => { + self.popup = PopupMode::default(); + self.message = "completion_popup = as-you-type (the default)".into(); + } + Call::Set(SetArg::Show(name)) if name == "completion_popup" => { + let current = PopupMode::VALUES.iter().find(|(_, mode)| *mode == self.popup); + self.message = format!("completion_popup = {}", current.map_or("?", |(name, _)| *name)); + } Call::Help(_) => { self.message = "the help screen comes in stage 7; Tab completes any command".into() } @@ -559,7 +612,7 @@ impl Playground { let next = matches.iter().copied().find(|&id| order.iter().position(|&o| o == id) > Some(here)); match next.or_else(|| matches.first().copied()) { Some(id) => { - self.jump(Some(id)); + self.leap(Some(id)); self.message = format!("{} match(es) for \"{text}\"", matches.len()); } None => self.message = format!("nothing matches \"{text}\""), @@ -649,6 +702,14 @@ impl Playground { } } + /// A jump `''` can come back from (to a mark, `gg`, `G`, a search). + fn leap(&mut self, target: Option) { + if target.is_some() { + self.before_jump = Some(self.cursor); + } + self.jump(target); + } + fn zoom(&mut self, closer: bool) { let zoom = if closer { self.camera.zoom.closer() } else { self.camera.zoom.further() }; if let Some(node) = self.doc.node(self.cursor) { @@ -707,12 +768,32 @@ impl Playground { } } - fn delete_branch(&mut self) { - let id = self.cursor; - let next = self.doc.parent(id).or_else(|| self.doc.previous_sibling(id)); - if let Some(removed) = self.edit(|edit| edit.remove_branch(id)) { + /// `dd` (or `dab`), with a count: this node and the next siblings, each + /// with its branch, as `3dd` deletes three lines in vim. + fn delete_branches(&mut self, count: u32) { + let branches = self.doc.siblings_from(self.cursor, usize::try_from(count).unwrap_or(1)); + let Some(&last) = branches.last() else { return }; + // The cursor goes to the sibling after them, or before them, or up. + let next = self + .doc + .next_sibling(last) + .or_else(|| self.doc.previous_sibling(self.cursor)) + .or_else(|| self.doc.parent(self.cursor)); + let removed = self.edit(|edit| { + let mut removed = 0; + for &branch in &branches { + removed += edit.remove_branch(branch)?.len(); + } + Ok(removed) + }); + if let Some(removed) = removed { self.cursor = next.unwrap_or(self.doc.root()); - self.message = format!("deleted {} node(s) · u to undo", removed.len()); + let what = if branches.len() == 1 { + "1 branch".to_string() + } else { + format!("{} branches", branches.len()) + }; + self.message = format!("deleted {what}, {removed} node(s) in all · u to undo"); } } diff --git a/src/input/command.rs b/src/input/command.rs index 2bc1a85..e35a8a9 100644 --- a/src/input/command.rs +++ b/src/input/command.rs @@ -115,7 +115,9 @@ impl CharKey { 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")], + CharKey::JumpToMark => { + &[("a–z A–Z", "go to that mark"), ("'", "back to where you were before the last jump")] + } _ => &[("any key", "the character to go to")], } } diff --git a/src/input/engine.rs b/src/input/engine.rs index 05e193c..4b096a7 100644 --- a/src/input/engine.rs +++ b/src/input/engine.rs @@ -186,10 +186,19 @@ impl KeyEngine { let entries = next_keys(&self.keys, &tables.commands_starting_with(&self.keys), tables); Some(Hints { title, entries }) } - Stage::Target { operator, operator_keys, .. } => { + Stage::Target { operator, operator_keys, count } => { let mut entries = Vec::new(); if self.keys.is_empty() { - let help = "this node, with its branch".to_string(); + // A count takes in the next siblings, as `3dd` takes three + // lines in vim: say exactly how many. + let help = match multiply(*count, self.count).unwrap_or(1) { + 1 => "this node, with its branch".to_string(), + 2 => "2 nodes: this one and the next sibling, with their branches".to_string(), + many => format!( + "{many} nodes: this one and the next {} siblings, with their branches", + many - 1 + ), + }; entries.push(Hint { keys: notation(operator_keys), help, group: false }); } let targets = tables.targets_starting_with(&self.keys); @@ -430,6 +439,12 @@ mod tests { assert_eq!(keys, ["d", "a", "i"]); assert!(hints.entries[1].group); + engine.reset(); + type_keys(&mut engine, &keymap, "2d3"); + let hints = engine.hints(&tables).unwrap(); + assert_eq!(hints.title, "2d3 · delete"); + assert_eq!(hints.entries[0].help, "6 nodes: this one and the next 5 siblings, with their branches"); + engine.reset(); type_keys(&mut engine, &keymap, "\""); assert_eq!(engine.hints(&tables).unwrap().title, "\" · register"); diff --git a/src/model/document.rs b/src/model/document.rs index 5a9d694..6b8f945 100644 --- a/src/model/document.rs +++ b/src/model/document.rs @@ -281,6 +281,14 @@ impl Document { siblings.get(index.checked_sub(1)?).copied() } + /// `id` and the siblings after it, `count` nodes in all (fewer at the end + /// of the row): what a count on an operator acts on, as `3dd` deletes + /// three lines in vim. + pub fn siblings_from(&self, id: NodeId, count: usize) -> Vec { + let Some((siblings, index)) = self.siblings(id) else { return Vec::new() }; + siblings.into_iter().skip(index).take(count.max(1)).collect() + } + /// The row of siblings `id` is in, and its index there. fn siblings(&self, id: NodeId) -> Option<(Vec, usize)> { let siblings: Vec = match self.place(id)? { @@ -486,6 +494,9 @@ mod tests { assert!(doc.is_inside(g.cedar, g.root) && !doc.is_inside(g.root, g.cedar)); assert_eq!(doc.depth(g.cedar), 2); assert_eq!(doc.branch(g.beds), vec![g.beds, g.cedar]); + assert_eq!(doc.siblings_from(g.soil, 2), vec![g.soil, g.beds]); + assert_eq!(doc.siblings_from(g.beds, 9), vec![g.beds, g.watering], "fewer at the end of the row"); + assert_eq!(doc.siblings_from(g.cedar, 0), vec![g.cedar], "at least the node itself"); assert_eq!(doc.outline(), vec![g.root, g.soil, g.beds, g.cedar, g.watering, g.compost]); assert_eq!(Document::new(title("x")).node_count(), 1); } diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 0541e93..41cd34d 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -31,9 +31,31 @@ use crate::input::key::{Key, KeyName}; /// How many candidates the popup shows at once. const POPUP_ROWS: usize = 8; +/// When the completion popup shows (the `completion_popup` setting). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PopupMode { + /// As soon as there's something to complete. + #[default] + AsYouType, + /// Only after Tab, like vim's wildmenu. + OnTab, +} + +impl PopupMode { + /// The setting's values, as written in the config. + pub const VALUES: [(&str, PopupMode); 2] = + [("as-you-type", PopupMode::AsYouType), ("on-tab", PopupMode::OnTab)]; + + /// The mode a config value names. + pub fn named(value: &str) -> Option { + PopupMode::VALUES.iter().find(|(name, _)| *name == value).map(|(_, mode)| *mode) + } +} + /// The command line while it's open. #[derive(Debug, Clone, Default)] pub struct CommandLine { + popup: PopupMode, input: LineInput, completion: Completion, /// The candidate Tab put in the line, while Tab cycles through them. @@ -57,9 +79,9 @@ pub enum LineOutcome { } impl CommandLine { - /// An empty command line. - pub fn new(sources: &Sources, history: &History) -> CommandLine { - let mut line = CommandLine::default(); + /// An empty command line, with its popup showing as `popup` says. + pub fn new(sources: &Sources, history: &History, popup: PopupMode) -> CommandLine { + let mut line = CommandLine { popup, ..CommandLine::default() }; line.changed(sources, history); line } @@ -186,7 +208,10 @@ impl CommandLine { /// The candidates, in a box above the word they'd finish. fn render_popup(&self, frame: &mut Frame, line: Rect, above: Rect, theme: &Theme) { let candidates = &self.completion.candidates; - let wanted = !self.input.text().is_empty() || self.selected.is_some(); + let wanted = match self.popup { + PopupMode::AsYouType => !self.input.text().is_empty() || self.selected.is_some(), + PopupMode::OnTab => self.selected.is_some(), + }; if candidates.is_empty() || !wanted || above.height < 3 { return; } @@ -243,14 +268,12 @@ mod tests { Sources { colors: vec!["red".into(), "orange".into()], ..Sources::default() } } - #[test] - fn the_popup_sits_above_the_word_it_finishes() { + /// Draws the command line at the bottom of a 60×8 screen: its rows, and + /// where the cursor ended up. + fn draw(line: &CommandLine) -> (Vec, Position) { use ratatui::Terminal; use ratatui::backend::TestBackend; - let history = History::default(); - let mut line = CommandLine::new(&sources(), &history); - press(&mut line, "fold a", &history); let mut terminal = Terminal::new(TestBackend::new(60, 8)).unwrap(); terminal .draw(|frame| { @@ -260,12 +283,40 @@ mod tests { line.render(frame, bottom, above, &Theme::default()); }) .unwrap(); - let buffer = terminal.backend().buffer().clone(); - let row = |y: u16| (0..60).map(|x| buffer[(x, y)].symbol().to_string()).collect::(); - assert_eq!(row(4).trim_end(), " ╭──────────────────────────────────────╮"); - assert_eq!(row(5).trim_end(), " │ auto shows them while you're inside │"); - assert_eq!(row(7).trim_end(), ":fold auto", "\"uto\" is the ghost text"); - assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(7, 7)); + let buffer = terminal.backend().buffer(); + let rows = (0..8) + .map(|y| { + (0..60) + .map(|x| buffer[(x, y)].symbol().to_string()) + .collect::() + .trim_end() + .to_string() + }) + .collect(); + (rows, terminal.get_cursor_position().unwrap()) + } + + #[test] + fn the_popup_sits_above_the_word_it_finishes() { + let history = History::default(); + let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType); + press(&mut line, "fold a", &history); + let (rows, cursor) = draw(&line); + assert_eq!(rows[4], " ╭──────────────────────────────────────╮"); + assert_eq!(rows[5], " │ auto shows them while you're inside │"); + assert_eq!(rows[7], ":fold auto", "\"uto\" is the ghost text"); + assert_eq!(cursor, Position::new(7, 7)); + } + + #[test] + fn on_tab_the_popup_waits_for_tab() { + let history = History::default(); + let mut line = CommandLine::new(&sources(), &history, PopupMode::OnTab); + press(&mut line, "fold a", &history); + assert!(draw(&line).0[..7].iter().all(String::is_empty), "no popup yet"); + press(&mut line, "", &history); + assert_eq!(draw(&line).0[5], " │ auto shows them while you're inside │"); + assert_eq!(PopupMode::named("on-tab"), Some(PopupMode::OnTab)); } fn press(line: &mut CommandLine, keys: &str, history: &History) -> LineOutcome { @@ -279,7 +330,7 @@ mod tests { #[test] fn tab_cycles_through_the_candidates() { let history = History::default(); - let mut line = CommandLine::new(&sources(), &history); + let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType); press(&mut line, "fo", &history); press(&mut line, "", &history); assert_eq!(line.text(), "fold"); @@ -293,12 +344,12 @@ mod tests { #[test] fn ghost_text_comes_from_history_first() { let history = History::from_lines(vec!["color orange".into()]); - let mut line = CommandLine::new(&sources(), &history); + let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType); press(&mut line, "color o", &history); assert_eq!(line.ghost.as_deref(), Some("range")); press(&mut line, "", &history); assert_eq!(line.text(), "color orange"); - let mut fresh = CommandLine::new(&sources(), &History::default()); + let mut fresh = CommandLine::new(&sources(), &History::default(), PopupMode::AsYouType); press(&mut fresh, "col", &History::default()); assert_eq!(fresh.ghost.as_deref(), Some("or"), "no history: the best candidate"); } @@ -306,7 +357,7 @@ mod tests { #[test] fn up_and_down_browse_matching_lines() { let history = History::from_lines(vec!["fold open".into(), "q".into(), "fold auto".into()]); - let mut line = CommandLine::new(&sources(), &history); + let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType); press(&mut line, "fo", &history); assert_eq!(line.text(), "fold auto"); press(&mut line, "", &history); @@ -319,7 +370,7 @@ mod tests { #[test] fn backspace_on_an_empty_line_closes_it() { let history = History::default(); - let mut line = CommandLine::new(&sources(), &history); + let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType); assert_eq!(press(&mut line, "q", &history), LineOutcome::Typing); assert_eq!(press(&mut line, "", &history), LineOutcome::Close); }