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]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 03:53:51 -04:00
1 parent c2e62de172
commit bfc184f5ef
14 files changed
+2094 -11

No files matched your search

+570
View File
@@ -0,0 +1,570 @@
//! A command line, understood: [`parse`] turns its text into a typed
//! [`Call`], checking every argument, or says clearly what's wrong.
use std::path::PathBuf;
use thiserror::Error;
use super::complete::{edit_distance, rank};
use super::registry::{self, Choice, CommandId as Id, Spec};
use crate::model::{Border, ColorName, Fold, NodeId, StyleName, Tag, Title};
use crate::view::ViewKind;
/// How `:tidy` arranges a branch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layout {
/// A mind map, growing to the right.
Map,
/// An org chart, growing down.
Chart,
/// A grid, for idea dumps.
Grid,
}
/// Which edges `:align` lines up.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Left,
Right,
Top,
Bottom,
Center,
}
/// What `:export` writes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Markdown,
Text,
}
/// What the board's columns are.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoardColumns {
Tags,
Colors,
}
/// One change `:tag` makes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TagChange {
Add(Tag),
Remove(Tag),
}
/// What `:set` does with a setting, the way vim's `:set` reads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SetArg {
/// `name?`: show its value.
Show(String),
/// `name&`: back to the default.
Reset(String),
/// `name`: turn an on/off setting on.
On(String),
/// `noname`: turn it off.
Off(String),
/// `name!`: flip it.
Toggle(String),
/// `name=value`.
Value(String, String),
}
/// What a command line asks for, with its arguments checked and typed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Call {
Edit {
path: String,
force: bool,
},
New(Option<Title>),
Write,
WriteAll,
Quit {
force: bool,
},
QuitAll {
force: bool,
},
WriteQuit,
Buffers,
/// Switch to the open document with this name.
Buffer(String),
CloseDocument {
force: bool,
},
Rename(Title),
Split(Option<ViewKind>),
VSplit(Option<ViewKind>),
Only,
ClosePane,
Show(ViewKind),
Board(Option<BoardColumns>),
Capture(String),
Link(NodeId),
Unlink(Option<NodeId>),
Portal(String),
BranchOut,
Tags(Vec<TagChange>),
/// A palette color, or `None` to clear it.
Color(Option<ColorName>),
Style(StyleName),
Border(Border),
Fold(Fold),
Focus,
Tidy(Layout),
Align(Side),
Find(String),
Backlinks,
Jumps,
Marks,
Registers,
Set(SetArg),
Config,
Help(Option<String>),
Version,
Theme(String),
Export {
format: ExportFormat,
path: Option<String>,
},
}
/// What the command line can name, from the app: names are checked against
/// these when parsing, and offered when completing.
#[derive(Debug, Clone, Default)]
pub struct Sources {
/// The open documents' names.
pub documents: Vec<String>,
/// This document's nodes, with their titles.
pub nodes: Vec<(NodeId, String)>,
/// The palette's colors.
pub colors: Vec<String>,
/// The theme's named styles.
pub styles: Vec<String>,
pub themes: Vec<String>,
/// The settings `:set` can change.
pub settings: Vec<String>,
/// The tags used in this document.
pub tags: Vec<String>,
/// Where relative file paths start: the document's folder.
pub folder: PathBuf,
}
/// Why a command line can't run.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CallError {
#[error("type a command, like :help")]
Empty,
#[error("there's no command :{name}{}", did_you_mean(.suggestion))]
Unknown { name: String, suggestion: Option<&'static str> },
#[error(":{0} doesn't take a !")]
NoBang(&'static str),
#[error("{usage}: the {what} is missing")]
Missing { usage: String, what: &'static str },
#[error("{usage}: {extra:?} is more than it takes")]
TooMuch { usage: String, extra: String },
#[error("{given:?} isn't a {what}: use {choices}")]
NotAChoice { what: &'static str, given: String, choices: String },
#[error("there's no {what} like {given:?}")]
NoMatch { what: &'static str, given: String },
#[error("{0}")]
Invalid(String),
}
fn did_you_mean(suggestion: &Option<&str>) -> String {
suggestion.map(|name| format!("; did you mean :{name}?")).unwrap_or_default()
}
/// Reads a command line (without its `:`).
pub fn parse(line: &str, sources: &Sources) -> Result<Call, CallError> {
let line = line.trim().trim_start_matches(':').trim_start();
let name_end = line.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(line.len());
let (name, after) = line.split_at(name_end);
if name.is_empty() {
return Err(CallError::Empty);
}
let (bang, rest) = match after.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, after),
};
let spec = registry::find(name)
.ok_or_else(|| CallError::Unknown { name: name.to_string(), suggestion: suggestion(name) })?;
if bang && !spec.bang {
return Err(CallError::NoBang(spec.name));
}
let args = read_args(spec, rest.trim())?;
build(spec, bang, &args, sources)
}
/// A command name close to a mistyped one: a letter or two off.
fn suggestion(name: &str) -> Option<&'static str> {
registry::SPECS
.iter()
.map(|spec| (edit_distance(name, spec.name), spec.name))
.filter(|(distance, _)| *distance <= 2)
.min()
.map(|(_, name)| name)
}
/// Splits what follows the command's name into its arguments: one word
/// each, or the rest of the line for the kinds that take it. An optional
/// argument left out is `None`.
fn read_args<'a>(spec: &Spec, mut rest: &'a str) -> Result<Vec<Option<&'a str>>, CallError> {
let mut values = Vec::new();
for arg in spec.args {
let value = if rest.is_empty() {
None
} else if arg.kind.takes_the_rest() {
Some(std::mem::take(&mut rest))
} else {
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let (word, after) = rest.split_at(end);
rest = after.trim_start();
Some(word)
};
if value.is_none() && arg.required {
return Err(CallError::Missing { usage: spec.usage(), what: arg.name });
}
values.push(value);
}
if !rest.is_empty() {
return Err(CallError::TooMuch { usage: spec.usage(), extra: rest.to_string() });
}
Ok(values)
}
/// The call, from a command and its arguments as typed. (Required
/// arguments are there: `read_args` checked.)
fn build(spec: &Spec, bang: bool, args: &[Option<&str>], sources: &Sources) -> Result<Call, CallError> {
let first = args.first().copied().flatten();
let second = args.get(1).copied().flatten();
let text = first.unwrap_or_default();
Ok(match spec.id {
Id::Edit => Call::Edit { path: text.to_string(), force: bang },
Id::New => Call::New(first.map(title).transpose()?),
Id::Write => Call::Write,
Id::WriteAll => Call::WriteAll,
Id::Quit => Call::Quit { force: bang },
Id::QuitAll => Call::QuitAll { force: bang },
Id::WriteQuit => Call::WriteQuit,
Id::Buffers => Call::Buffers,
Id::Buffer => Call::Buffer(best("open document", text, &sources.documents)?.to_string()),
Id::CloseDocument => Call::CloseDocument { force: bang },
Id::Rename => Call::Rename(title(text)?),
Id::Split => Call::Split(first.map(view).transpose()?),
Id::VSplit => Call::VSplit(first.map(view).transpose()?),
Id::Only => Call::Only,
Id::ClosePane => Call::ClosePane,
Id::Canvas => Call::Show(ViewKind::Canvas),
Id::Outline => Call::Show(ViewKind::Outline),
Id::Note => Call::Show(ViewKind::Note),
Id::Board => Call::Board(first.map(board_columns).transpose()?),
Id::Capture => Call::Capture(text.to_string()),
Id::Link => Call::Link(node(text, &sources.nodes)?),
Id::Unlink => Call::Unlink(first.map(|query| node(query, &sources.nodes)).transpose()?),
Id::Portal => Call::Portal(text.to_string()),
Id::BranchOut => Call::BranchOut,
Id::Tags => Call::Tags(tag_changes(text)?),
Id::Color => Call::Color(color(text, &sources.colors)?),
Id::Style => Call::Style(style(text, &sources.styles)?),
Id::Border => Call::Border(border(text)?),
Id::Fold => Call::Fold(fold(text)?),
Id::Focus => Call::Focus,
Id::Tidy => Call::Tidy(first.map(layout).transpose()?.unwrap_or(Layout::Map)),
Id::Align => Call::Align(side(text)?),
Id::Find => Call::Find(text.to_string()),
Id::Backlinks => Call::Backlinks,
Id::Jumps => Call::Jumps,
Id::Marks => Call::Marks,
Id::Registers => Call::Registers,
Id::Set => Call::Set(set_arg(text, &sources.settings)?),
Id::Config => Call::Config,
Id::Help => Call::Help(first.map(str::to_string)),
Id::Version => Call::Version,
Id::Theme => Call::Theme(exactly("theme", text, &sources.themes)?.to_string()),
Id::Export => Call::Export { format: export_format(text)?, path: second.map(str::to_string) },
})
}
fn title(text: &str) -> Result<Title, CallError> {
Title::parse(text).map_err(|error| CallError::Invalid(error.to_string()))
}
/// The best fuzzy match for `query` among `names`.
fn best<'a>(what: &'static str, query: &str, names: &'a [String]) -> Result<&'a str, CallError> {
rank(query, names.iter().map(String::as_str))
.first()
.copied()
.ok_or_else(|| CallError::NoMatch { what, given: query.to_string() })
}
/// The node whose title matches `query`: an exact title first, then the best
/// fuzzy match.
fn node(query: &str, nodes: &[(NodeId, String)]) -> Result<NodeId, CallError> {
let exact = nodes.iter().find(|(_, title)| title.eq_ignore_ascii_case(query));
let fuzzy = || {
let best = rank(query, nodes.iter().map(|(_, title)| title.as_str())).first().copied()?;
nodes.iter().find(|(_, title)| title == best)
};
exact
.or_else(fuzzy)
.map(|(id, _)| *id)
.ok_or_else(|| CallError::NoMatch { what: "node", given: query.to_string() })
}
/// A name that has to be one of `names` exactly (ignoring case).
fn exactly<'a>(what: &'static str, given: &str, names: &'a [String]) -> Result<&'a str, CallError> {
names.iter().find(|name| name.eq_ignore_ascii_case(given)).map(String::as_str).ok_or_else(|| {
if names.is_empty() {
CallError::NoMatch { what, given: given.to_string() }
} else {
CallError::NotAChoice { what, given: given.to_string(), choices: names.join(", ") }
}
})
}
fn color(given: &str, colors: &[String]) -> Result<Option<ColorName>, CallError> {
if given.eq_ignore_ascii_case("none") {
return Ok(None);
}
let name = exactly("color", given, colors)?;
ColorName::parse(name).map(Some).map_err(|error| CallError::Invalid(error.to_string()))
}
fn style(given: &str, styles: &[String]) -> Result<StyleName, CallError> {
let name = exactly("style", given, styles)?;
StyleName::parse(name).map_err(|error| CallError::Invalid(error.to_string()))
}
fn tag_changes(text: &str) -> Result<Vec<TagChange>, CallError> {
let invalid = |error: crate::model::TextError| CallError::Invalid(error.to_string());
text.split_whitespace()
.map(|word| match word.strip_prefix('-') {
Some(name) => Tag::parse(name).map(TagChange::Remove).map_err(invalid),
None => Tag::parse(word.trim_start_matches('+')).map(TagChange::Add).map_err(invalid),
})
.collect()
}
fn set_arg(text: &str, settings: &[String]) -> Result<SetArg, CallError> {
let known = |name: &str| settings.iter().any(|setting| setting == name);
let check = |name: &str| {
if known(name) {
Ok(name.to_string())
} else {
Err(CallError::NoMatch { what: "setting", given: name.into() })
}
};
if let Some((name, value)) = text.split_once('=') {
return Ok(SetArg::Value(check(name.trim())?, value.trim().to_string()));
}
if let Some(name) = text.strip_suffix('?') {
return Ok(SetArg::Show(check(name)?));
}
if let Some(name) = text.strip_suffix('&') {
return Ok(SetArg::Reset(check(name)?));
}
if let Some(name) = text.strip_suffix('!') {
return Ok(SetArg::Toggle(check(name)?));
}
if let Some(name) = text.strip_prefix("no")
&& known(name)
{
return Ok(SetArg::Off(name.to_string()));
}
Ok(SetArg::On(check(text)?))
}
/// The error for a word that isn't one of the choices.
fn not_a_choice(what: &'static str, given: &str, choices: &[Choice]) -> CallError {
let words: Vec<&str> = choices.iter().map(|choice| choice.word).collect();
CallError::NotAChoice { what, given: given.to_string(), choices: words.join(", ") }
}
fn view(word: &str) -> Result<ViewKind, CallError> {
ViewKind::ALL
.into_iter()
.find(|kind| kind.name() == word.to_ascii_lowercase())
.ok_or_else(|| not_a_choice("view", word, registry::VIEWS))
}
fn board_columns(word: &str) -> Result<BoardColumns, CallError> {
match word.to_ascii_lowercase().as_str() {
"tag" | "tags" => Ok(BoardColumns::Tags),
"color" | "colors" => Ok(BoardColumns::Colors),
_ => Err(not_a_choice("way to make columns", word, registry::BOARD_COLUMNS)),
}
}
fn border(word: &str) -> Result<Border, CallError> {
match word.to_ascii_lowercase().as_str() {
"rounded" => Ok(Border::Rounded),
"square" => Ok(Border::Square),
"double" => Ok(Border::Double),
"thick" => Ok(Border::Thick),
"dashed" => Ok(Border::Dashed),
"hidden" | "none" => Ok(Border::Hidden),
_ => Err(not_a_choice("border", word, registry::BORDERS)),
}
}
fn fold(word: &str) -> Result<Fold, CallError> {
match word.to_ascii_lowercase().as_str() {
"open" => Ok(Fold::Open),
"auto" => Ok(Fold::Auto),
"closed" | "close" => Ok(Fold::Closed),
_ => Err(not_a_choice("way to fold", word, registry::FOLDS)),
}
}
fn layout(word: &str) -> Result<Layout, CallError> {
match word.to_ascii_lowercase().as_str() {
"map" => Ok(Layout::Map),
"chart" => Ok(Layout::Chart),
"grid" => Ok(Layout::Grid),
_ => Err(not_a_choice("layout", word, registry::LAYOUTS)),
}
}
fn side(word: &str) -> Result<Side, CallError> {
match word.to_ascii_lowercase().as_str() {
"left" => Ok(Side::Left),
"right" => Ok(Side::Right),
"top" => Ok(Side::Top),
"bottom" => Ok(Side::Bottom),
"center" | "middle" => Ok(Side::Center),
_ => Err(not_a_choice("side", word, registry::SIDES)),
}
}
fn export_format(word: &str) -> Result<ExportFormat, CallError> {
match word.to_ascii_lowercase().as_str() {
"md" | "markdown" => Ok(ExportFormat::Markdown),
"txt" | "text" => Ok(ExportFormat::Text),
_ => Err(not_a_choice("format", word, registry::FORMATS)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sources() -> Sources {
let names = |list: &[&str]| list.iter().map(|name| name.to_string()).collect();
Sources {
documents: names(&["Garden plans", "Kitchen", "Reading list"]),
nodes: vec![
(NodeId::new(1), "Garden plans".into()),
(NodeId::new(2), "Raised beds".into()),
(NodeId::new(3), "Rain barrel".into()),
],
colors: names(&["red", "orange", "teal"]),
styles: names(&["question"]),
themes: names(&["default", "light"]),
settings: names(&["autosave", "mouse"]),
tags: names(&["todo"]),
folder: PathBuf::from("."),
}
}
fn call(line: &str) -> Result<Call, CallError> {
parse(line, &sources())
}
#[test]
fn names_bangs_and_arguments() {
assert_eq!(call("q"), Ok(Call::Quit { force: false }));
assert_eq!(call(":qa!"), Ok(Call::QuitAll { force: true }));
assert_eq!(call("x"), Ok(Call::WriteQuit));
assert_eq!(
call("e notes/garden.cum"),
Ok(Call::Edit { path: "notes/garden.cum".into(), force: false })
);
assert_eq!(call("vs outline"), Ok(Call::VSplit(Some(ViewKind::Outline))));
assert_eq!(call("in buy a rain barrel"), Ok(Call::Capture("buy a rain barrel".into())));
assert_eq!(call("fold auto"), Ok(Call::Fold(Fold::Auto)));
assert_eq!(call("tidy"), Ok(Call::Tidy(Layout::Map)));
assert_eq!(call("color none"), Ok(Call::Color(None)));
assert_eq!(
call("export md out.md"),
Ok(Call::Export { format: ExportFormat::Markdown, path: Some("out.md".into()) })
);
}
#[test]
fn names_are_matched_fuzzily_where_it_helps() {
assert_eq!(call("b gpl"), Ok(Call::Buffer("Garden plans".into())));
assert_eq!(call("link rbar"), Ok(Call::Link(NodeId::new(3))));
assert_eq!(call("link raised beds"), Ok(Call::Link(NodeId::new(2))), "an exact title wins");
let changes = call("tag +idea -todo later").unwrap();
let tag = |name| Tag::parse(name).unwrap();
assert_eq!(
changes,
Call::Tags(vec![
TagChange::Add(tag("idea")),
TagChange::Remove(tag("todo")),
TagChange::Add(tag("later"))
])
);
assert_eq!(call("set nomouse"), Ok(Call::Set(SetArg::Off("mouse".into()))));
assert_eq!(call("set autosave?"), Ok(Call::Set(SetArg::Show("autosave".into()))));
}
#[test]
fn mistakes_say_what_to_do() {
let message = |line: &str| call(line).unwrap_err().to_string();
assert_eq!(message(""), "type a command, like :help");
assert_eq!(message("folx open"), "there's no command :folx; did you mean :fold?");
assert_eq!(message("w!"), ":write doesn't take a !");
assert_eq!(message("e"), ":e[dit][!] <file>: the file is missing");
assert_eq!(message("fold up"), "\"up\" isn't a way to fold: use open, auto, closed");
assert_eq!(message("fold open now"), ":fo[ld] <open|auto|closed>: \"now\" is more than it takes");
assert_eq!(message("color corla"), "\"corla\" isn't a color: use red, orange, teal");
assert_eq!(message("b xyz"), "there's no open document like \"xyz\"");
assert_eq!(message("set colour=1"), "there's no setting like \"colour\"");
}
#[test]
fn every_choice_word_is_understood() {
let check = |choices: &[Choice], read: &dyn Fn(&str) -> bool| {
for choice in choices {
assert!(read(choice.word), "{}", choice.word);
}
};
check(registry::VIEWS, &|word| view(word).is_ok());
check(registry::BORDERS, &|word| border(word).is_ok());
check(registry::FOLDS, &|word| fold(word).is_ok());
check(registry::LAYOUTS, &|word| layout(word).is_ok());
check(registry::SIDES, &|word| side(word).is_ok());
check(registry::FORMATS, &|word| export_format(word).is_ok());
check(registry::BOARD_COLUMNS, &|word| board_columns(word).is_ok());
}
#[test]
fn every_command_can_be_typed() {
let samples = [
("edit", "x.cum"),
("buffer", "kitchen"),
("rename", "New name"),
("in", "an idea"),
("link", "rain"),
("portal", "x.cum"),
("tag", "+x"),
("color", "red"),
("style", "question"),
("border", "thick"),
("fold", "open"),
("align", "left"),
("find", "beds"),
("set", "mouse"),
("theme", "light"),
("export", "md"),
];
for spec in registry::SPECS {
let argument =
samples.iter().find(|(name, _)| *name == spec.name).map_or("", |(_, argument)| argument);
let line = format!("{} {argument}", spec.name);
assert!(call(&line).is_ok(), ":{line} → {:?}", call(&line));
}
}
}