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:
1 parent
c2e62de172
commit
bfc184f5ef
14 files changed
+2094
-11
No files matched your search
@@ -0,0 +1,317 @@
|
||||
//! Completion: what can finish the word being typed on the command line.
|
||||
//! Command names complete by prefix, as in vim (`:e` → `:edit`); documents
|
||||
//! and nodes fuzzily (`:b gpl` → Garden plans); files from the folders on
|
||||
//! disk; everything else from its list of choices.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::parse::Sources;
|
||||
use super::registry::{self, ArgKind, Spec};
|
||||
|
||||
/// At most this many candidates are offered.
|
||||
const MAX_CANDIDATES: usize = 50;
|
||||
|
||||
/// A way to finish the word being typed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
/// What replaces the word.
|
||||
pub text: String,
|
||||
/// What it is or does, shown beside it.
|
||||
pub help: String,
|
||||
}
|
||||
|
||||
/// The completion of a line: where the word being completed starts (a byte
|
||||
/// offset in the line) and the candidates, best first.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Completion {
|
||||
pub start: usize,
|
||||
pub candidates: Vec<Candidate>,
|
||||
}
|
||||
|
||||
impl Completion {
|
||||
/// What the best candidate adds to the word typed so far, when it
|
||||
/// carries on from it: the ghost text after the cursor.
|
||||
pub fn ghost<'a>(&'a self, line: &str) -> Option<&'a str> {
|
||||
let typed = line.get(self.start..)?;
|
||||
let best = self.candidates.first()?;
|
||||
best.text.strip_prefix(typed).filter(|rest| !rest.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes the word at the end of `line` (the text after `:`).
|
||||
pub fn complete(line: &str, sources: &Sources) -> Completion {
|
||||
let name_start = line.len() - line.trim_start().len();
|
||||
let name_end =
|
||||
line[name_start..].find(|c: char| !c.is_ascii_alphabetic()).map_or(line.len(), |at| name_start + at);
|
||||
let name = &line[name_start..name_end];
|
||||
if name_end == line.len() {
|
||||
return Completion { start: name_start, candidates: command_names(name) };
|
||||
}
|
||||
let Some(spec) = registry::find(name) else { return Completion::default() };
|
||||
let after = line[name_end..].strip_prefix('!').unwrap_or(&line[name_end..]);
|
||||
if !after.starts_with(char::is_whitespace) {
|
||||
return Completion::default();
|
||||
}
|
||||
let args_start = line.len() - after.trim_start().len();
|
||||
let Some((kind, start)) = current_argument(spec, line, args_start) else { return Completion::default() };
|
||||
let typed = &line[start..];
|
||||
let mut candidates = match kind {
|
||||
ArgKind::Choice(choices) => choices
|
||||
.iter()
|
||||
.filter(|choice| starts_with_ignoring_case(choice.word, typed))
|
||||
.map(|choice| candidate(choice.word, choice.help))
|
||||
.collect(),
|
||||
ArgKind::File => paths(typed, &sources.folder),
|
||||
ArgKind::Document => rank(typed, sources.documents.iter().map(String::as_str))
|
||||
.into_iter()
|
||||
.map(|name| candidate(name, ""))
|
||||
.collect(),
|
||||
ArgKind::Node => rank(typed, sources.nodes.iter().map(|(_, title)| title.as_str()))
|
||||
.into_iter()
|
||||
.map(|title| candidate(title, ""))
|
||||
.collect(),
|
||||
ArgKind::Color => std::iter::once("none")
|
||||
.chain(sources.colors.iter().map(String::as_str))
|
||||
.filter(|name| starts_with_ignoring_case(name, typed))
|
||||
.map(|name| candidate(name, if name == "none" { "no color" } else { "" }))
|
||||
.collect(),
|
||||
ArgKind::Style => names(typed, &sources.styles),
|
||||
ArgKind::Theme => names(typed, &sources.themes),
|
||||
ArgKind::Setting => names(typed, &sources.settings),
|
||||
ArgKind::Topic => registry::SPECS
|
||||
.iter()
|
||||
.filter(|spec| spec.name.starts_with(typed))
|
||||
.map(|spec| candidate(spec.name, spec.help))
|
||||
.collect(),
|
||||
ArgKind::Tags => {
|
||||
let (sign, name) = typed.split_at(usize::from(typed.starts_with(['+', '-'])));
|
||||
sources
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| starts_with_ignoring_case(tag, name))
|
||||
.map(|tag| candidate(&format!("{sign}{tag}"), ""))
|
||||
.collect()
|
||||
}
|
||||
ArgKind::Text => Vec::new(),
|
||||
};
|
||||
candidates.truncate(MAX_CANDIDATES);
|
||||
Completion { start, candidates }
|
||||
}
|
||||
|
||||
/// Which argument the end of the line is in, and where its word starts.
|
||||
/// Tags and settings complete their last word; other kinds that take the
|
||||
/// rest of the line complete all of it.
|
||||
fn current_argument(spec: &Spec, line: &str, args_start: usize) -> Option<(ArgKind, usize)> {
|
||||
let mut start = args_start;
|
||||
for arg in spec.args {
|
||||
let rest = &line[start..];
|
||||
match arg.kind {
|
||||
ArgKind::Tags | ArgKind::Setting => {
|
||||
let last_word = rest.rfind(char::is_whitespace).map_or(0, |space| space + 1);
|
||||
return Some((arg.kind, start + last_word));
|
||||
}
|
||||
kind if kind.takes_the_rest() => return Some((kind, start)),
|
||||
kind => match rest.find(char::is_whitespace) {
|
||||
// The word is done: completion moves on to the next argument.
|
||||
Some(end) => start += end + (rest.len() - end - rest[end..].trim_start().len()),
|
||||
None => return Some((kind, start)),
|
||||
},
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Command names starting with `typed`. A name whose short form is exactly
|
||||
/// what's typed comes first (`:e` offers `edit` before `export`).
|
||||
fn command_names(typed: &str) -> Vec<Candidate> {
|
||||
let mut specs: Vec<&Spec> = registry::SPECS.iter().filter(|spec| spec.name.starts_with(typed)).collect();
|
||||
specs.sort_by_key(|spec| (spec.short != typed, spec.name));
|
||||
let mut candidates: Vec<Candidate> = specs.iter().map(|spec| candidate(spec.name, spec.help)).collect();
|
||||
// Aliases, unless their command is listed already (`colour` beside `color`).
|
||||
for spec in registry::SPECS.iter().filter(|spec| !spec.name.starts_with(typed)) {
|
||||
if let Some(alias) = spec.aliases.iter().find(|alias| alias.starts_with(typed) && !typed.is_empty()) {
|
||||
candidates.push(candidate(alias, spec.help));
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Names from a list that start with `typed`.
|
||||
fn names(typed: &str, names: &[String]) -> Vec<Candidate> {
|
||||
names
|
||||
.iter()
|
||||
.filter(|name| starts_with_ignoring_case(name, typed))
|
||||
.map(|name| candidate(name, ""))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Files and folders that finish a typed path. Folders come first and end
|
||||
/// with `/`; hidden ones only show when the name typed starts with a dot.
|
||||
fn paths(typed: &str, folder: &Path) -> Vec<Candidate> {
|
||||
let split = typed.rfind(['/', '\\']).map_or(0, |slash| slash + 1);
|
||||
let (dir, start) = typed.split_at(split);
|
||||
let dir_path = if Path::new(dir).is_absolute() { PathBuf::from(dir) } else { folder.join(dir) };
|
||||
let Ok(entries) = std::fs::read_dir(dir_path) else { return Vec::new() };
|
||||
let mut found: Vec<(bool, String)> = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name().into_string().ok()?;
|
||||
let shown = !name.starts_with('.') || start.starts_with('.');
|
||||
let is_folder = entry.file_type().ok()?.is_dir();
|
||||
(shown && starts_with_ignoring_case(&name, start)).then_some((is_folder, name))
|
||||
})
|
||||
.collect();
|
||||
found.sort_by_key(|(is_folder, name)| (!is_folder, name.to_lowercase()));
|
||||
found
|
||||
.into_iter()
|
||||
.map(|(is_folder, name)| {
|
||||
let help = if is_folder {
|
||||
"folder"
|
||||
} else if name.ends_with(".cum") {
|
||||
"document"
|
||||
} else {
|
||||
"file"
|
||||
};
|
||||
candidate(&format!("{dir}{name}{}", if is_folder { "/" } else { "" }), help)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn candidate(text: &str, help: &str) -> Candidate {
|
||||
Candidate { text: text.to_string(), help: help.to_string() }
|
||||
}
|
||||
|
||||
fn starts_with_ignoring_case(text: &str, start: &str) -> bool {
|
||||
text.to_lowercase().starts_with(&start.to_lowercase())
|
||||
}
|
||||
|
||||
/// How well `query` matches `text`: `None` unless its characters all appear
|
||||
/// in `text`, in order, ignoring case. Matches at the start of a word and
|
||||
/// matches in a row score higher, so "gpl" picks "Garden plans".
|
||||
pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
|
||||
let mut wanted = query.chars().flat_map(char::to_lowercase).peekable();
|
||||
let mut score = 0;
|
||||
let mut previous: Option<char> = None;
|
||||
let mut previous_matched = false;
|
||||
for c in text.chars() {
|
||||
let Some(&next) = wanted.peek() else { break };
|
||||
let matched = c.to_lowercase().eq(std::iter::once(next));
|
||||
if matched {
|
||||
score += 1;
|
||||
if previous.is_none_or(|before| !before.is_alphanumeric()) {
|
||||
score += 8;
|
||||
}
|
||||
if previous_matched {
|
||||
score += 4;
|
||||
}
|
||||
wanted.next();
|
||||
}
|
||||
previous_matched = matched;
|
||||
previous = Some(c);
|
||||
}
|
||||
if wanted.peek().is_some() {
|
||||
return None;
|
||||
}
|
||||
// Among equal matches, the shorter text is the closer one.
|
||||
Some(score * 16 - text.chars().count() as i32)
|
||||
}
|
||||
|
||||
/// The texts that `query` fuzzily matches, best first. An empty query
|
||||
/// matches everything, in the order given.
|
||||
pub fn rank<'a>(query: &str, texts: impl IntoIterator<Item = &'a str>) -> Vec<&'a str> {
|
||||
let mut scored: Vec<(i32, usize, &str)> = texts
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(order, text)| Some((fuzzy_score(query, text)?, order, text)))
|
||||
.collect();
|
||||
if !query.is_empty() {
|
||||
scored.sort_by_key(|&(score, order, _)| (-score, order));
|
||||
}
|
||||
scored.into_iter().map(|(_, _, text)| text).collect()
|
||||
}
|
||||
|
||||
/// How many letters to add, remove or change to turn `a` into `b`
|
||||
/// (Levenshtein distance): how far a typo is from a name.
|
||||
pub fn edit_distance(a: &str, b: &str) -> usize {
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let mut row: Vec<usize> = (0..=b.len()).collect();
|
||||
for (i, x) in a.chars().enumerate() {
|
||||
let mut diagonal = row[0];
|
||||
row[0] = i + 1;
|
||||
for (j, y) in b.iter().enumerate() {
|
||||
let above = row[j + 1];
|
||||
row[j + 1] = (above + 1).min(row[j] + 1).min(diagonal + usize::from(x != *y));
|
||||
diagonal = above;
|
||||
}
|
||||
}
|
||||
row[b.len()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::NodeId;
|
||||
|
||||
fn sources() -> Sources {
|
||||
let names = |list: &[&str]| list.iter().map(|name| name.to_string()).collect();
|
||||
Sources {
|
||||
documents: names(&["Garden plans", "Kitchen", "Grocery list"]),
|
||||
nodes: vec![(NodeId::new(1), "Raised beds".into()), (NodeId::new(2), "Rain barrel".into())],
|
||||
colors: names(&["red", "orange", "teal"]),
|
||||
tags: names(&["todo", "later"]),
|
||||
..Sources::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn texts(line: &str) -> Vec<String> {
|
||||
complete(line, &sources()).candidates.into_iter().map(|candidate| candidate.text).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_names_complete_by_prefix() {
|
||||
assert_eq!(texts("e"), ["edit", "export"], "the short form's command first");
|
||||
assert_eq!(texts("fo"), ["fold", "focus"]);
|
||||
assert_eq!(texts("colo"), ["color", "colorscheme"]);
|
||||
let all = texts("");
|
||||
assert_eq!(all.len(), registry::SPECS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_complete_by_kind() {
|
||||
assert_eq!(texts("fold a"), ["auto"]);
|
||||
assert_eq!(texts("b gl"), ["Grocery list", "Garden plans"], "fuzzy: word starts in a row first");
|
||||
assert_eq!(texts("link rb"), ["Raised beds", "Rain barrel"], "equally good: their own order");
|
||||
assert_eq!(texts("color "), ["none", "red", "orange", "teal"]);
|
||||
assert_eq!(texts("tag +idea -t"), ["-todo"]);
|
||||
assert_eq!(texts("export md "), Vec::<String>::new(), "a file in the current folder (none here)");
|
||||
assert_eq!(texts("q! x"), Vec::<String>::new(), "nothing to complete");
|
||||
let completion = complete("vs out", &sources());
|
||||
assert_eq!((completion.start, completion.ghost("vs out")), (3, Some("line")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_complete_from_the_folder() {
|
||||
let folder = std::env::temp_dir().join(format!("cumulus-complete-{}", std::process::id()));
|
||||
std::fs::create_dir_all(folder.join("notes")).unwrap();
|
||||
std::fs::write(folder.join("garden.cum"), "").unwrap();
|
||||
std::fs::write(folder.join("Garage.txt"), "").unwrap();
|
||||
std::fs::write(folder.join(".hidden"), "").unwrap();
|
||||
let sources = Sources { folder: folder.clone(), ..Sources::default() };
|
||||
let found = |line: &str| -> Vec<String> {
|
||||
complete(line, &sources).candidates.into_iter().map(|candidate| candidate.text).collect()
|
||||
};
|
||||
assert_eq!(found("e "), ["notes/", "Garage.txt", "garden.cum"], "folders first, hidden files hidden");
|
||||
assert_eq!(found("e ga"), ["Garage.txt", "garden.cum"]);
|
||||
assert_eq!(found("e notes/"), Vec::<String>::new());
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_and_typo_matching() {
|
||||
assert!(fuzzy_score("gpl", "Garden plans") > fuzzy_score("gpl", "a grape pile"));
|
||||
assert_eq!(fuzzy_score("xyz", "Garden plans"), None);
|
||||
assert_eq!(edit_distance("folx", "fold"), 1);
|
||||
assert_eq!(edit_distance("", "abc"), 3);
|
||||
assert_eq!(edit_distance("kitten", "sitting"), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! 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"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! The command line, opened with `:` like vim's.
|
||||
//!
|
||||
//! - [`registry`]: every command, with how short it can be typed, its
|
||||
//! arguments and what it does.
|
||||
//! - [`parse`]: a line of text to a typed [`Call`](parse::Call), with clear
|
||||
//! errors.
|
||||
//! - [`complete`]: what can finish the word being typed.
|
||||
//! - [`history`]: earlier lines, for Up and Down.
|
||||
//!
|
||||
//! The line being typed and its popup are in
|
||||
//! [`ui::command_line`](crate::ui::command_line).
|
||||
|
||||
pub mod complete;
|
||||
pub mod history;
|
||||
pub mod parse;
|
||||
pub mod registry;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Every command of the command line: its name, how short it can be typed,
|
||||
//! its arguments and what it does. Parsing, completion and the help all read
|
||||
//! this one table.
|
||||
|
||||
/// Which command a spec describes, so code matches on this (and the compiler
|
||||
/// checks every command is handled) instead of on names.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CommandId {
|
||||
Edit,
|
||||
New,
|
||||
Write,
|
||||
WriteAll,
|
||||
Quit,
|
||||
QuitAll,
|
||||
WriteQuit,
|
||||
Buffers,
|
||||
Buffer,
|
||||
CloseDocument,
|
||||
Rename,
|
||||
Split,
|
||||
VSplit,
|
||||
Only,
|
||||
ClosePane,
|
||||
Canvas,
|
||||
Outline,
|
||||
Note,
|
||||
Board,
|
||||
Capture,
|
||||
Link,
|
||||
Unlink,
|
||||
Portal,
|
||||
BranchOut,
|
||||
Tags,
|
||||
Color,
|
||||
Style,
|
||||
Border,
|
||||
Fold,
|
||||
Focus,
|
||||
Tidy,
|
||||
Align,
|
||||
Find,
|
||||
Backlinks,
|
||||
Jumps,
|
||||
Marks,
|
||||
Registers,
|
||||
Set,
|
||||
Config,
|
||||
Help,
|
||||
Version,
|
||||
Theme,
|
||||
Export,
|
||||
}
|
||||
|
||||
/// A word an argument can be, and what it means.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Choice {
|
||||
pub word: &'static str,
|
||||
pub help: &'static str,
|
||||
}
|
||||
|
||||
/// What kind of value an argument takes: it decides how the argument is read
|
||||
/// and what completion offers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ArgKind {
|
||||
/// One of a few words.
|
||||
Choice(&'static [Choice]),
|
||||
/// A path to a file, completed from the folders on disk.
|
||||
File,
|
||||
/// An open document, by name.
|
||||
Document,
|
||||
/// A node of this document, by title.
|
||||
Node,
|
||||
/// A color from the palette, or `none`.
|
||||
Color,
|
||||
/// A named style from the theme.
|
||||
Style,
|
||||
/// A theme, by name.
|
||||
Theme,
|
||||
/// Tags to add (`+idea`, or just `idea`) and remove (`-todo`).
|
||||
Tags,
|
||||
/// A setting, vim style: `name=value`, `name?`, `name&`, `noname`, `name!`.
|
||||
Setting,
|
||||
/// A help topic: a command's name.
|
||||
Topic,
|
||||
/// Any text: the rest of the line.
|
||||
Text,
|
||||
}
|
||||
|
||||
impl ArgKind {
|
||||
/// Whether the argument is the rest of the line (spaces included),
|
||||
/// rather than one word.
|
||||
pub fn takes_the_rest(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ArgKind::File
|
||||
| ArgKind::Document
|
||||
| ArgKind::Node
|
||||
| ArgKind::Text
|
||||
| ArgKind::Tags
|
||||
| ArgKind::Setting
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One argument of a command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Arg {
|
||||
pub name: &'static str,
|
||||
pub kind: ArgKind,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// A command of the command line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Spec {
|
||||
pub id: CommandId,
|
||||
/// The full name: `edit`.
|
||||
pub name: &'static str,
|
||||
/// The shortest way to type it, as in vim: `e` for `:e[dit]`. Anything
|
||||
/// from this to the full name works.
|
||||
pub short: &'static str,
|
||||
/// Other names, typed in full: `x` for `wq`.
|
||||
pub aliases: &'static [&'static str],
|
||||
/// Whether it takes a `!`, like `:q!`.
|
||||
pub bang: bool,
|
||||
pub args: &'static [Arg],
|
||||
pub help: &'static str,
|
||||
}
|
||||
|
||||
impl Spec {
|
||||
/// Whether `typed` names this command.
|
||||
pub fn is_named(&self, typed: &str) -> bool {
|
||||
(self.name.starts_with(typed) && typed.starts_with(self.short)) || self.aliases.contains(&typed)
|
||||
}
|
||||
|
||||
/// How to type it, for errors and the help: `:e[dit][!] <file>`.
|
||||
pub fn usage(&self) -> String {
|
||||
let rest = &self.name[self.short.len()..];
|
||||
let mut usage = format!(":{}", self.short);
|
||||
if !rest.is_empty() {
|
||||
usage.push_str(&format!("[{rest}]"));
|
||||
}
|
||||
if self.bang {
|
||||
usage.push_str("[!]");
|
||||
}
|
||||
for arg in self.args {
|
||||
let name = match arg.kind {
|
||||
ArgKind::Choice(choices) => {
|
||||
choices.iter().map(|choice| choice.word).collect::<Vec<_>>().join("|")
|
||||
}
|
||||
_ => arg.name.to_string(),
|
||||
};
|
||||
usage.push_str(&if arg.required { format!(" <{name}>") } else { format!(" [{name}]") });
|
||||
}
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
/// The command a typed name stands for.
|
||||
pub fn find(typed: &str) -> Option<&'static Spec> {
|
||||
SPECS.iter().find(|spec| spec.is_named(typed))
|
||||
}
|
||||
|
||||
const fn choice(word: &'static str, help: &'static str) -> Choice {
|
||||
Choice { word, help }
|
||||
}
|
||||
|
||||
pub const VIEWS: &[Choice] = &[
|
||||
choice("canvas", "the 2D map"),
|
||||
choice("outline", "the indented tree"),
|
||||
choice("note", "one node, to write in"),
|
||||
choice("board", "columns by tag or color"),
|
||||
];
|
||||
|
||||
pub const BORDERS: &[Choice] = &[
|
||||
choice("rounded", "╭─╮"),
|
||||
choice("square", "┌─┐"),
|
||||
choice("double", "╔═╗"),
|
||||
choice("thick", "┏━┓"),
|
||||
choice("dashed", "┌╌┐"),
|
||||
choice("hidden", "no border"),
|
||||
];
|
||||
|
||||
pub const FOLDS: &[Choice] = &[
|
||||
choice("open", "always shows its children"),
|
||||
choice("auto", "shows them while you're inside"),
|
||||
choice("closed", "hides them"),
|
||||
];
|
||||
|
||||
pub const LAYOUTS: &[Choice] = &[
|
||||
choice("map", "a mind map, growing to the right"),
|
||||
choice("chart", "an org chart, growing down"),
|
||||
choice("grid", "a grid, for idea dumps"),
|
||||
];
|
||||
|
||||
pub const SIDES: &[Choice] = &[
|
||||
choice("left", "left edges in a line"),
|
||||
choice("right", "right edges in a line"),
|
||||
choice("top", "top edges in a line"),
|
||||
choice("bottom", "bottom edges in a line"),
|
||||
choice("center", "centers in a column"),
|
||||
];
|
||||
|
||||
pub const FORMATS: &[Choice] = &[choice("md", "Markdown"), choice("txt", "plain text")];
|
||||
|
||||
pub const BOARD_COLUMNS: &[Choice] =
|
||||
&[choice("tag", "a column per tag"), choice("color", "a column per color")];
|
||||
|
||||
const fn required(name: &'static str, kind: ArgKind) -> Arg {
|
||||
Arg { name, kind, required: true }
|
||||
}
|
||||
|
||||
const fn optional(name: &'static str, kind: ArgKind) -> Arg {
|
||||
Arg { name, kind, required: false }
|
||||
}
|
||||
|
||||
const fn spec(
|
||||
id: CommandId,
|
||||
name: &'static str,
|
||||
short: &'static str,
|
||||
args: &'static [Arg],
|
||||
help: &'static str,
|
||||
) -> Spec {
|
||||
Spec { id, name, short, aliases: &[], bang: false, args, help }
|
||||
}
|
||||
|
||||
const fn with_bang(spec: Spec) -> Spec {
|
||||
Spec { bang: true, ..spec }
|
||||
}
|
||||
|
||||
const fn with_aliases(aliases: &'static [&'static str], spec: Spec) -> Spec {
|
||||
Spec { aliases, ..spec }
|
||||
}
|
||||
|
||||
use ArgKind as K;
|
||||
use CommandId as Id;
|
||||
|
||||
/// Every command, in the order the help lists them.
|
||||
pub const SPECS: &[Spec] = &[
|
||||
// Files and documents.
|
||||
with_bang(spec(
|
||||
Id::Edit,
|
||||
"edit",
|
||||
"e",
|
||||
&[required("file", K::File)],
|
||||
"open a document, or make it (! reloads it)",
|
||||
)),
|
||||
spec(Id::New, "new", "new", &[optional("title", K::Text)], "start a new document next to this one"),
|
||||
spec(Id::Write, "write", "w", &[], "save now (cumulus saves by itself, too)"),
|
||||
spec(Id::WriteAll, "wall", "wa", &[], "save every document"),
|
||||
with_bang(spec(
|
||||
Id::Quit,
|
||||
"quit",
|
||||
"q",
|
||||
&[],
|
||||
"close the pane; the last one quits (! drops unsaved changes)",
|
||||
)),
|
||||
with_bang(with_aliases(
|
||||
&["quitall"],
|
||||
spec(Id::QuitAll, "qall", "qa", &[], "quit cumulus (! drops unsaved changes)"),
|
||||
)),
|
||||
with_aliases(&["x", "xit"], spec(Id::WriteQuit, "wq", "wq", &[], "save, then close the pane")),
|
||||
with_aliases(&["ls", "files"], spec(Id::Buffers, "buffers", "buffers", &[], "list the open documents")),
|
||||
spec(Id::Buffer, "buffer", "b", &[required("document", K::Document)], "switch to an open document"),
|
||||
with_bang(spec(Id::CloseDocument, "bdelete", "bd", &[], "close this document (! drops unsaved changes)")),
|
||||
spec(
|
||||
Id::Rename,
|
||||
"rename",
|
||||
"ren",
|
||||
&[required("title", K::Text)],
|
||||
"rename the document (its root's title)",
|
||||
),
|
||||
// Panes and views.
|
||||
spec(Id::Split, "split", "sp", &[optional("view", K::Choice(VIEWS))], "split the pane, above and below"),
|
||||
spec(Id::VSplit, "vsplit", "vs", &[optional("view", K::Choice(VIEWS))], "split the pane, side by side"),
|
||||
spec(Id::Only, "only", "on", &[], "close the other panes"),
|
||||
spec(Id::ClosePane, "close", "clo", &[], "close this pane"),
|
||||
spec(Id::Canvas, "canvas", "can", &[], "show the canvas"),
|
||||
spec(Id::Outline, "outline", "out", &[], "show the outline"),
|
||||
spec(Id::Note, "note", "not", &[], "show the Note view"),
|
||||
spec(Id::Board, "board", "boa", &[optional("columns", K::Choice(BOARD_COLUMNS))], "show the board"),
|
||||
// Nodes.
|
||||
spec(Id::Capture, "in", "in", &[required("idea", K::Text)], "capture an idea to the inbox"),
|
||||
spec(Id::Link, "link", "li", &[required("node", K::Node)], "link this node to another one"),
|
||||
spec(Id::Unlink, "unlink", "unl", &[optional("node", K::Node)], "remove a link (or all of this node's)"),
|
||||
spec(Id::Portal, "portal", "por", &[required("file", K::File)], "add a portal to another document"),
|
||||
spec(Id::BranchOut, "branchout", "bran", &[], "move this branch into a document of its own"),
|
||||
with_aliases(
|
||||
&["tags"],
|
||||
spec(Id::Tags, "tag", "ta", &[required("tags", K::Tags)], "add and remove tags: +idea -todo"),
|
||||
),
|
||||
with_aliases(
|
||||
&["colour"],
|
||||
spec(Id::Color, "color", "col", &[required("color", K::Color)], "color this node (none: no color)"),
|
||||
),
|
||||
spec(Id::Style, "style", "sty", &[required("style", K::Style)], "give this node a named style"),
|
||||
spec(Id::Border, "border", "bor", &[required("border", K::Choice(BORDERS))], "this node's border"),
|
||||
spec(Id::Fold, "fold", "fo", &[required("fold", K::Choice(FOLDS))], "how this branch folds"),
|
||||
spec(Id::Focus, "focus", "foc", &[], "focus on or off: fold the other branches"),
|
||||
spec(Id::Tidy, "tidy", "tid", &[optional("layout", K::Choice(LAYOUTS))], "arrange this branch"),
|
||||
spec(Id::Align, "align", "al", &[required("side", K::Choice(SIDES))], "line up the selected nodes"),
|
||||
// Finding.
|
||||
spec(Id::Find, "find", "fin", &[required("text", K::Text)], "search titles, bodies and #tags"),
|
||||
spec(Id::Backlinks, "backlinks", "back", &[], "the portals that lead here"),
|
||||
spec(Id::Jumps, "jumps", "ju", &[], "the jump list"),
|
||||
spec(Id::Marks, "marks", "marks", &[], "the marks"),
|
||||
with_aliases(&["display"], spec(Id::Registers, "registers", "reg", &[], "what the registers hold")),
|
||||
// Settings and help.
|
||||
spec(
|
||||
Id::Set,
|
||||
"set",
|
||||
"se",
|
||||
&[required("setting", K::Setting)],
|
||||
"change a setting: name=value, name?, noname",
|
||||
),
|
||||
spec(Id::Config, "config", "conf", &[], "open the config files"),
|
||||
spec(Id::Help, "help", "h", &[optional("topic", K::Topic)], "every key and command"),
|
||||
spec(Id::Version, "version", "ve", &[], "which cumulus this is"),
|
||||
with_aliases(
|
||||
&["colorscheme"],
|
||||
spec(Id::Theme, "theme", "them", &[required("theme", K::Theme)], "switch the theme"),
|
||||
),
|
||||
// Export.
|
||||
spec(
|
||||
Id::Export,
|
||||
"export",
|
||||
"exp",
|
||||
&[required("format", K::Choice(FORMATS)), optional("file", K::File)],
|
||||
"export the document",
|
||||
),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every way to type a command (its short form up to its full name, and
|
||||
/// its aliases) names that command and no other.
|
||||
#[test]
|
||||
fn every_name_is_unambiguous() {
|
||||
for spec in SPECS {
|
||||
let mut ways: Vec<&str> =
|
||||
(spec.short.len()..=spec.name.len()).map(|end| &spec.name[..end]).collect();
|
||||
ways.extend(spec.aliases);
|
||||
for typed in ways {
|
||||
let named: Vec<&str> =
|
||||
SPECS.iter().filter(|other| other.is_named(typed)).map(|s| s.name).collect();
|
||||
assert_eq!(named, [spec.name], ":{typed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_forms_work_like_vim() {
|
||||
assert_eq!(find("e").map(|spec| spec.id), Some(CommandId::Edit));
|
||||
assert_eq!(find("edi").map(|spec| spec.id), Some(CommandId::Edit));
|
||||
assert_eq!(find("x").map(|spec| spec.id), Some(CommandId::WriteQuit));
|
||||
assert_eq!(find("colorscheme").map(|spec| spec.id), Some(CommandId::Theme));
|
||||
assert_eq!(find("colo").map(|spec| spec.id), Some(CommandId::Color));
|
||||
assert_eq!(find("s"), None, "too short to tell split, set and style apart");
|
||||
assert_eq!(find("edits"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_shows_how_to_type_it() {
|
||||
assert_eq!(find("e").unwrap().usage(), ":e[dit][!] <file>");
|
||||
assert_eq!(find("fold").unwrap().usage(), ":fo[ld] <open|auto|closed>");
|
||||
assert_eq!(find("export").unwrap().usage(), ":exp[ort] <md|txt> [file]");
|
||||
assert_eq!(find("in").unwrap().usage(), ":in <idea>");
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -8,10 +8,12 @@
|
||||
//! |-------------|--------------------------------------------------------------|
|
||||
//! | [`model`] | the data and its rules: documents, nodes, links, undo, folds |
|
||||
//! | [`input`] | keys and the mouse: vim keys, which-key, keys.toml |
|
||||
//! | [`command`] | the `:` command line: commands, parsing, completion, history |
|
||||
//! | [`storage`] | files: documents (RON) |
|
||||
//! | [`view`] | the views, one module each: so far, the canvas |
|
||||
//! | [`ui`] | what views share: glyphs, the theme, the which-key popup |
|
||||
//! | [`ui`] | what views share: glyphs, theme, which-key, the command line |
|
||||
|
||||
pub mod command;
|
||||
pub mod input;
|
||||
pub mod model;
|
||||
pub mod storage;
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
//! The command line at the bottom of the screen, opened with `:`: the line
|
||||
//! being typed, a completion popup above it, ghost text after the cursor,
|
||||
//! and Up and Down through the history.
|
||||
//!
|
||||
//! ```text
|
||||
//! ╭─────────────────────────────────╮
|
||||
//! │ fold how this branch folds │
|
||||
//! │ focus focus on or off: fold … │
|
||||
//! ╰─────────────────────────────────╯
|
||||
//! :fo▏ld
|
||||
//! ```
|
||||
//!
|
||||
//! Tab and Shift-Tab put the candidates in the line one by one; → (or
|
||||
//! `Ctrl-e`, End) at the end of the line takes the ghost text; Enter runs
|
||||
//! the line; Esc closes it.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Clear, Paragraph};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::line_input::LineInput;
|
||||
use super::theme::Theme;
|
||||
use crate::command::complete::{Completion, complete};
|
||||
use crate::command::history::History;
|
||||
use crate::command::parse::Sources;
|
||||
use crate::input::key::{Key, KeyName};
|
||||
|
||||
/// How many candidates the popup shows at once.
|
||||
const POPUP_ROWS: usize = 8;
|
||||
|
||||
/// The command line while it's open.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CommandLine {
|
||||
input: LineInput,
|
||||
completion: Completion,
|
||||
/// The candidate Tab put in the line, while Tab cycles through them.
|
||||
selected: Option<usize>,
|
||||
/// While browsing the history: what was typed first (only lines starting
|
||||
/// with it show), and which line is in the input.
|
||||
browsing: Option<(String, usize)>,
|
||||
/// What the ghost text would add after the cursor.
|
||||
ghost: Option<String>,
|
||||
}
|
||||
|
||||
/// What a key in the command line leads to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LineOutcome {
|
||||
/// Still typing.
|
||||
Typing,
|
||||
/// Enter: run this line.
|
||||
Run(String),
|
||||
/// Esc, or Backspace on an empty line: close without running anything.
|
||||
Close,
|
||||
}
|
||||
|
||||
impl CommandLine {
|
||||
/// An empty command line.
|
||||
pub fn new(sources: &Sources, history: &History) -> CommandLine {
|
||||
let mut line = CommandLine::default();
|
||||
line.changed(sources, history);
|
||||
line
|
||||
}
|
||||
|
||||
/// What's typed so far.
|
||||
pub fn text(&self) -> &str {
|
||||
self.input.text()
|
||||
}
|
||||
|
||||
/// Handles a key.
|
||||
pub fn key(&mut self, key: Key, sources: &Sources, history: &History) -> LineOutcome {
|
||||
if key.is_escape() {
|
||||
return LineOutcome::Close;
|
||||
}
|
||||
match (key.name, key.ctrl) {
|
||||
(KeyName::Enter, _) => return LineOutcome::Run(self.input.text().to_string()),
|
||||
(KeyName::Backspace, _) if self.input.text().is_empty() => return LineOutcome::Close,
|
||||
(KeyName::Tab, false) => self.cycle(true),
|
||||
(KeyName::BackTab, _) => self.cycle(false),
|
||||
(KeyName::Up, _) => self.browse(true, sources, history),
|
||||
(KeyName::Down, _) => self.browse(false, sources, history),
|
||||
(KeyName::Right | KeyName::End, _) | (KeyName::Char('e'), true)
|
||||
if self.input.at_end() && self.ghost.is_some() =>
|
||||
{
|
||||
if let Some(ghost) = self.ghost.take() {
|
||||
self.input.insert(&ghost);
|
||||
self.changed(sources, history);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if self.input.edit(key) {
|
||||
self.changed(sources, history);
|
||||
}
|
||||
}
|
||||
}
|
||||
LineOutcome::Typing
|
||||
}
|
||||
|
||||
/// After the text changed by typing: new candidates and ghost text, and
|
||||
/// any cycling or browsing stops.
|
||||
fn changed(&mut self, sources: &Sources, history: &History) {
|
||||
self.selected = None;
|
||||
self.browsing = None;
|
||||
self.refresh(sources, history);
|
||||
}
|
||||
|
||||
fn refresh(&mut self, sources: &Sources, history: &History) {
|
||||
let text = self.input.text();
|
||||
self.completion = complete(text, sources);
|
||||
self.ghost = if self.input.at_end() && !text.is_empty() {
|
||||
match history.suggestion(text) {
|
||||
Some(line) => Some(line[text.len()..].to_string()),
|
||||
None => self.completion.ghost(text).map(str::to_string),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
/// Tab: puts the next candidate (or the previous one, for Shift-Tab) in
|
||||
/// place of the word being completed.
|
||||
fn cycle(&mut self, forward: bool) {
|
||||
let count = self.completion.candidates.len();
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let next = match (self.selected, forward) {
|
||||
(None, true) => 0,
|
||||
(None, false) => count - 1,
|
||||
(Some(index), true) => (index + 1) % count,
|
||||
(Some(index), false) => (index + count - 1) % count,
|
||||
};
|
||||
let text = &self.completion.candidates[next].text;
|
||||
self.input.replace(self.completion.start, self.input.text().len(), text);
|
||||
self.selected = Some(next);
|
||||
self.ghost = None;
|
||||
}
|
||||
|
||||
/// Up and Down: older and newer lines that start with what was typed.
|
||||
fn browse(&mut self, older: bool, sources: &Sources, history: &History) {
|
||||
let (typed, at) = match &self.browsing {
|
||||
Some((typed, at)) => (typed.clone(), Some(*at)),
|
||||
None => (self.input.text().to_string(), None),
|
||||
};
|
||||
let found = match (older, at) {
|
||||
(true, _) => history.older(&typed, at),
|
||||
(false, Some(at)) => history.newer(&typed, at),
|
||||
(false, None) => None,
|
||||
};
|
||||
match (found.and_then(|index| Some((index, history.get(index)?))), older) {
|
||||
(Some((index, line)), _) => {
|
||||
self.input.set(line);
|
||||
self.refresh(sources, history);
|
||||
self.selected = None;
|
||||
self.browsing = Some((typed, index));
|
||||
}
|
||||
// Down past the newest line: back to what was typed.
|
||||
(None, false) if at.is_some() => {
|
||||
self.input.set(&typed);
|
||||
self.changed(sources, history);
|
||||
}
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the line in `line` (one row), and the popup in `above` (right
|
||||
/// above it). Places the terminal's cursor in the line.
|
||||
pub fn render(&self, frame: &mut Frame, line: Rect, above: Rect, theme: &Theme) {
|
||||
let prompt = Span::styled(":", Style::new().fg(theme.accent).add_modifier(Modifier::BOLD));
|
||||
let mut spans = vec![prompt, Span::raw(self.input.text())];
|
||||
if let Some(ghost) = &self.ghost {
|
||||
spans.push(Span::styled(ghost.as_str(), Style::new().fg(theme.muted)));
|
||||
}
|
||||
frame.render_widget(Clear, line);
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), line);
|
||||
let column = u16::try_from(1 + self.input.cursor_column()).unwrap_or(u16::MAX);
|
||||
frame.set_cursor_position(Position::new(
|
||||
line.x.saturating_add(column).min(line.right().saturating_sub(1)),
|
||||
line.y,
|
||||
));
|
||||
self.render_popup(frame, line, above, theme);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
if candidates.is_empty() || !wanted || above.height < 3 {
|
||||
return;
|
||||
}
|
||||
let rows = candidates.len().min(POPUP_ROWS).min(usize::from(above.height - 2));
|
||||
// Scroll so the candidate Tab picked stays in view.
|
||||
let first = self.selected.map_or(0, |selected| selected.saturating_sub(rows - 1));
|
||||
let shown = &candidates[first..(first + rows).min(candidates.len())];
|
||||
let text_width = shown.iter().map(|candidate| candidate.text.width()).max().unwrap_or(0);
|
||||
let help_width = shown.iter().map(|candidate| candidate.help.width()).max().unwrap_or(0);
|
||||
let inner = text_width + if help_width > 0 { help_width + 2 } else { 0 } + 2;
|
||||
let width = u16::try_from(inner + 2).unwrap_or(u16::MAX).min(above.width);
|
||||
let height = u16::try_from(rows + 2).unwrap_or(u16::MAX);
|
||||
// Line the candidates up with the word they finish.
|
||||
let word_column = 1 + self.input.text().get(..self.completion.start).unwrap_or("").width();
|
||||
let x = (line.x + u16::try_from(word_column).unwrap_or(0)).saturating_sub(2);
|
||||
let rect = Rect {
|
||||
x: x.max(above.x).min(above.right().saturating_sub(width)),
|
||||
y: above.bottom().saturating_sub(height),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
let lines: Vec<Line> = shown
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row, candidate)| {
|
||||
let picked = self.selected == Some(first + row);
|
||||
let (text_style, help_style) = if picked {
|
||||
let style = Style::new().fg(Color::Black).bg(theme.accent);
|
||||
(style, style)
|
||||
} else {
|
||||
(Style::new(), Style::new().fg(theme.muted))
|
||||
};
|
||||
let mut spans = vec![Span::styled(format!(" {:<text_width$} ", candidate.text), text_style)];
|
||||
if help_width > 0 {
|
||||
spans.push(Span::styled(format!(" {:<help_width$} ", candidate.help), help_style));
|
||||
}
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
let block =
|
||||
Block::bordered().border_type(BorderType::Rounded).border_style(Style::new().fg(theme.muted));
|
||||
frame.render_widget(Clear, rect);
|
||||
frame.render_widget(Paragraph::new(lines).block(block), rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::input::key::parse_keys;
|
||||
|
||||
fn sources() -> Sources {
|
||||
Sources { colors: vec!["red".into(), "orange".into()], ..Sources::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_popup_sits_above_the_word_it_finishes() {
|
||||
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| {
|
||||
let area = frame.area();
|
||||
let bottom = Rect { y: area.bottom() - 1, height: 1, ..area };
|
||||
let above = Rect { height: area.height - 1, ..area };
|
||||
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));
|
||||
}
|
||||
|
||||
fn press(line: &mut CommandLine, keys: &str, history: &History) -> LineOutcome {
|
||||
let mut outcome = LineOutcome::Typing;
|
||||
for key in parse_keys(keys, Key::char(' ')).unwrap() {
|
||||
outcome = line.key(key, &sources(), history);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_cycles_through_the_candidates() {
|
||||
let history = History::default();
|
||||
let mut line = CommandLine::new(&sources(), &history);
|
||||
press(&mut line, "fo", &history);
|
||||
press(&mut line, "<Tab>", &history);
|
||||
assert_eq!(line.text(), "fold");
|
||||
press(&mut line, "<Tab>", &history);
|
||||
assert_eq!(line.text(), "focus");
|
||||
press(&mut line, "<S-Tab>", &history);
|
||||
assert_eq!(line.text(), "fold");
|
||||
assert_eq!(press(&mut line, " a<Tab><CR>", &history), LineOutcome::Run("fold auto".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghost_text_comes_from_history_first() {
|
||||
let history = History::from_lines(vec!["color orange".into()]);
|
||||
let mut line = CommandLine::new(&sources(), &history);
|
||||
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());
|
||||
press(&mut fresh, "col", &History::default());
|
||||
assert_eq!(fresh.ghost.as_deref(), Some("or"), "no history: the best candidate");
|
||||
}
|
||||
|
||||
#[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);
|
||||
press(&mut line, "fo<Up>", &history);
|
||||
assert_eq!(line.text(), "fold auto");
|
||||
press(&mut line, "<Up>", &history);
|
||||
assert_eq!(line.text(), "fold open");
|
||||
press(&mut line, "<Down><Down>", &history);
|
||||
assert_eq!(line.text(), "fo", "past the newest: back to what was typed");
|
||||
assert_eq!(press(&mut line, "<Esc>", &history), LineOutcome::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_on_an_empty_line_closes_it() {
|
||||
let history = History::default();
|
||||
let mut line = CommandLine::new(&sources(), &history);
|
||||
assert_eq!(press(&mut line, "q<BS>", &history), LineOutcome::Typing);
|
||||
assert_eq!(press(&mut line, "<BS>", &history), LineOutcome::Close);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! One line of text being typed, with its cursor: the command line now, and
|
||||
//! titles later. The cursor moves a whole character at a time, so an
|
||||
//! accented letter or an emoji made of several code points is one step.
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::input::key::{Key, KeyName};
|
||||
|
||||
/// A line of text and a cursor in it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct LineInput {
|
||||
text: String,
|
||||
/// A byte offset in `text`, always between two characters.
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl LineInput {
|
||||
/// A line holding `text`, with the cursor at its end.
|
||||
pub fn new(text: &str) -> LineInput {
|
||||
LineInput { text: text.to_string(), cursor: text.len() }
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Where the cursor is: a byte offset in the text.
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Whether the cursor is after the last character.
|
||||
pub fn at_end(&self) -> bool {
|
||||
self.cursor == self.text.len()
|
||||
}
|
||||
|
||||
/// The screen column of the cursor: how wide the text before it is.
|
||||
pub fn cursor_column(&self) -> usize {
|
||||
self.text[..self.cursor].width()
|
||||
}
|
||||
|
||||
/// Replaces the text, and puts the cursor at its end.
|
||||
pub fn set(&mut self, text: &str) {
|
||||
*self = LineInput::new(text);
|
||||
}
|
||||
|
||||
/// Replaces the bytes `from..to` with `text`, leaving the cursor after it.
|
||||
pub fn replace(&mut self, from: usize, to: usize, text: &str) {
|
||||
self.text.replace_range(from..to, text);
|
||||
self.cursor = from + text.len();
|
||||
}
|
||||
|
||||
/// Types text at the cursor.
|
||||
pub fn insert(&mut self, text: &str) {
|
||||
self.text.insert_str(self.cursor, text);
|
||||
self.cursor += text.len();
|
||||
}
|
||||
|
||||
/// Edits the line with one of the keys every text field shares. Returns
|
||||
/// false for keys it doesn't use, for the caller to handle.
|
||||
///
|
||||
/// | Key | What it does |
|
||||
/// |----------------------|--------------------------------------|
|
||||
/// | a character | types it |
|
||||
/// | Backspace, Del | deletes before / after the cursor |
|
||||
/// | ← →, Home, End | move the cursor |
|
||||
/// | `Ctrl-b`, `Ctrl-e` | to the start / the end (as in vim's command line) |
|
||||
/// | `Ctrl-w` | deletes the word before the cursor |
|
||||
/// | `Ctrl-u` | deletes everything before the cursor |
|
||||
pub fn edit(&mut self, key: Key) -> bool {
|
||||
match (key.name, key.ctrl) {
|
||||
(KeyName::Char(c), false) if !key.alt => self.insert(c.encode_utf8(&mut [0; 4])),
|
||||
(KeyName::Backspace, _) => {
|
||||
let start = self.previous_boundary();
|
||||
self.text.replace_range(start..self.cursor, "");
|
||||
self.cursor = start;
|
||||
}
|
||||
(KeyName::Delete, _) => {
|
||||
let end = self.next_boundary();
|
||||
self.text.replace_range(self.cursor..end, "");
|
||||
}
|
||||
(KeyName::Left, _) => self.cursor = self.previous_boundary(),
|
||||
(KeyName::Right, _) => self.cursor = self.next_boundary(),
|
||||
(KeyName::Home, _) | (KeyName::Char('b'), true) => self.cursor = 0,
|
||||
(KeyName::End, _) | (KeyName::Char('e'), true) => self.cursor = self.text.len(),
|
||||
(KeyName::Char('w'), true) => {
|
||||
let start = self.word_start_before();
|
||||
self.text.replace_range(start..self.cursor, "");
|
||||
self.cursor = start;
|
||||
}
|
||||
(KeyName::Char('u'), true) => {
|
||||
self.text.replace_range(..self.cursor, "");
|
||||
self.cursor = 0;
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Where the character before the cursor starts.
|
||||
fn previous_boundary(&self) -> usize {
|
||||
self.text[..self.cursor].grapheme_indices(true).next_back().map_or(0, |(start, _)| start)
|
||||
}
|
||||
|
||||
/// Where the character after the cursor ends.
|
||||
fn next_boundary(&self) -> usize {
|
||||
self.text[self.cursor..].graphemes(true).next().map_or(self.cursor, |next| self.cursor + next.len())
|
||||
}
|
||||
|
||||
/// Where `Ctrl-w` deletes back to, as in vim: past any spaces, then a run
|
||||
/// of letters and digits, or else a run of other signs (so `notes/` goes
|
||||
/// in two steps).
|
||||
fn word_start_before(&self) -> usize {
|
||||
let before = &self.text[..self.cursor];
|
||||
let end = before.trim_end().len();
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '_';
|
||||
let last_is_word = before[..end].chars().next_back().is_some_and(is_word);
|
||||
before[..end]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.take_while(|&(_, c)| !c.is_whitespace() && is_word(c) == last_is_word)
|
||||
.last()
|
||||
.map_or(end, |(start, _)| start)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::input::key::parse_keys;
|
||||
|
||||
fn typed(start: &str, keys: &str) -> LineInput {
|
||||
let mut line = LineInput::new(start);
|
||||
for key in parse_keys(keys, Key::char(' ')).unwrap() {
|
||||
line.edit(key);
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_and_moving() {
|
||||
let line = typed("", "e garden<Home><Right><Del>x<End>!");
|
||||
assert_eq!(line.text(), "exgarden!");
|
||||
assert_eq!(line.cursor(), line.text().len());
|
||||
assert_eq!(typed("café", "<BS>e").text(), "cafe", "é is one character");
|
||||
let flag = typed("ok 👍🏽", "<Left><Left>");
|
||||
assert_eq!(flag.cursor(), 2, "an emoji with a skin tone is one step");
|
||||
assert_eq!(flag.cursor_column(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_words_and_lines() {
|
||||
assert_eq!(typed("e notes/garden", "<C-w>").text(), "e notes/");
|
||||
assert_eq!(typed("e notes/", "<C-w>").text(), "e notes");
|
||||
assert_eq!(typed("fold open ", "<C-w>").text(), "fold ");
|
||||
assert_eq!(typed("fold open", "<Left><Left><C-u>").text(), "en");
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -1,7 +1,11 @@
|
||||
//! 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.
|
||||
//! colors and looks ([`theme`]), the which-key popup ([`which_key`]), and the
|
||||
//! command line ([`command_line`]) with the line input it types into
|
||||
//! ([`line_input`]). The bars and the other shared widgets come in later
|
||||
//! stages.
|
||||
|
||||
pub mod command_line;
|
||||
pub mod glyphs;
|
||||
pub mod line_input;
|
||||
pub mod theme;
|
||||
pub mod which_key;
|
||||
@@ -2,3 +2,30 @@
|
||||
//! all share; for now there's the canvas.
|
||||
|
||||
pub mod canvas;
|
||||
|
||||
/// The kinds of view a pane can show.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ViewKind {
|
||||
/// The 2D map.
|
||||
Canvas,
|
||||
/// The tree as an indented list.
|
||||
Outline,
|
||||
/// One node at full size, to write in.
|
||||
Note,
|
||||
/// Columns by tag or by color.
|
||||
Board,
|
||||
}
|
||||
|
||||
impl ViewKind {
|
||||
pub const ALL: [ViewKind; 4] = [ViewKind::Canvas, ViewKind::Outline, ViewKind::Note, ViewKind::Board];
|
||||
|
||||
/// The view's name, as the status bar and the command line say it.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ViewKind::Canvas => "canvas",
|
||||
ViewKind::Outline => "outline",
|
||||
ViewKind::Note => "note",
|
||||
ViewKind::Board => "board",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user