Files
cumulus/src/command/history.rs
T
skkeyeandClaude Opus 5.5 bfc184f5ef Add the command line: commands, completion, ghost text and history
`:` opens a vim-style command line. Every command is described once, in
a typed table (name, the short form that still works, aliases, a `!`,
its arguments and what it does), so parsing, completion and the help can
never disagree. Short forms work as in vim (`:e`, `:vs`, `:q!`, `:x`),
and a test checks that no typed name can mean two commands.

Parsing turns a line into a typed Call, checking every argument against
what the app offers (open documents, nodes, colors, styles, settings),
or says what's wrong in words: the usage when an argument is missing,
the choices when a word isn't one, "did you mean :fold?" for a typo.
Documents and nodes match fuzzily (`:b gpl` finds Garden plans).

While typing, a popup above the word lists what can finish it (command
names by prefix, choices, files from the folders on disk, documents and
nodes fuzzily), with Tab and Shift-Tab putting them in the line. Ghost
text after the cursor comes from the history first, like fish's, then
from the best candidate; → or Ctrl-e takes it. Up and Down browse the
lines that start with what was typed. The line itself is a small input
that steps over whole characters, accents and emoji included.

The canvas example opens it with `:` and runs a handful of commands:
:fold, :color, :border, :tag, :focus, :rename, :in (to an Inbox),
:link, :unlink, :find, :version and :q.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 03:53:51 -04:00

101 lines
3.3 KiB
Rust

//! Earlier command lines, for Up and Down and for the ghost text.
/// How many lines are kept.
const LIMIT: usize = 200;
/// Earlier command lines, oldest first. A line is kept once: running it
/// again moves it to the end.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct History {
lines: Vec<String>,
}
impl History {
/// A history holding these lines, oldest first (as loaded from disk).
pub fn from_lines(lines: Vec<String>) -> History {
let mut history = History::default();
for line in lines {
history.add(&line);
}
history
}
/// The lines, oldest first (to save them).
pub fn lines(&self) -> &[String] {
&self.lines
}
/// Remembers a line that was run.
pub fn add(&mut self, line: &str) {
let line = line.trim();
if line.is_empty() {
return;
}
self.lines.retain(|earlier| earlier != line);
self.lines.push(line.to_string());
if self.lines.len() > LIMIT {
self.lines.remove(0);
}
}
/// The line at an index (from [`older`](History::older) or
/// [`newer`](History::newer)).
pub fn get(&self, index: usize) -> Option<&str> {
self.lines.get(index).map(String::as_str)
}
/// The newest line before `before` (or before the end, for `None`) that
/// starts with `start`, as Up finds it.
pub fn older(&self, start: &str, before: Option<usize>) -> Option<usize> {
let end = before.unwrap_or(self.lines.len()).min(self.lines.len());
self.lines[..end].iter().rposition(|line| line.starts_with(start))
}
/// The oldest line after `after` that starts with `start`, as Down finds
/// it.
pub fn newer(&self, start: &str, after: usize) -> Option<usize> {
let from = after + 1;
self.lines.get(from..)?.iter().position(|line| line.starts_with(start)).map(|offset| from + offset)
}
/// The newest line that starts with `start` and goes further: what the
/// ghost text suggests, like fish's.
pub fn suggestion(&self, start: &str) -> Option<&str> {
if start.trim().is_empty() {
return None;
}
self.lines
.iter()
.rev()
.find(|line| line.starts_with(start) && line.len() > start.len())
.map(String::as_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn up_and_down_only_see_matching_lines() {
let history =
History::from_lines(vec!["fold open".into(), "e garden.cum".into(), "fold auto".into()]);
let up = history.older("fold", None).unwrap();
assert_eq!(history.get(up), Some("fold auto"));
let up_again = history.older("fold", Some(up)).unwrap();
assert_eq!(history.get(up_again), Some("fold open"));
assert_eq!(history.older("fold", Some(up_again)), None);
assert_eq!(history.newer("fold", up_again), Some(up));
assert_eq!(history.suggestion("e g"), Some("e garden.cum"));
assert_eq!(history.suggestion(""), None);
}
#[test]
fn running_a_line_again_moves_it_to_the_end() {
let mut history = History::from_lines(vec!["q".into(), "w".into()]);
history.add("q");
history.add(" ");
assert_eq!(history.lines(), ["w", "q"]);
}
}