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 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 04:17:41 -04:00
1 parent bfc184f5ef
commit 57ba2c275d
6 files changed
+199 -35

No files matched your search

+3 -1
View File
@@ -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")],
}
}
+17 -2
View File
@@ -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");
+11
View File
@@ -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<NodeId> {
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<NodeId>, usize)> {
let siblings: Vec<NodeId> = 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);
}
+71 -20
View File
@@ -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> {
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<String>, 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::<String>();
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::<String>()
.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, "<Tab>", &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, "<Tab>", &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, "<Right>", &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<Up>", &history);
assert_eq!(line.text(), "fold auto");
press(&mut line, "<Up>", &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<BS>", &history), LineOutcome::Typing);
assert_eq!(press(&mut line, "<BS>", &history), LineOutcome::Close);
}