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
Generated
+1
@@ -118,6 +118,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"toml",
|
"toml",
|
||||||
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ ratatui = { version = "0.30", default-features = false, features = ["crossterm_0
|
|||||||
# How many terminal columns text takes (accents, Asian scripts, emoji), for
|
# How many terminal columns text takes (accents, Asian scripts, emoji), for
|
||||||
# sizing node boxes.
|
# sizing node boxes.
|
||||||
unicode-width = "0.2"
|
unicode-width = "0.2"
|
||||||
|
# Where one character ends and the next begins, so the cursor steps over an
|
||||||
|
# accented letter or an emoji in one go.
|
||||||
|
unicode-segmentation = "1"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|||||||
@@ -58,6 +58,6 @@ cargo fmt # format the code
|
|||||||
While the app is being built, the examples try out each part:
|
While the app is being built, the examples try out each part:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run --example canvas # the canvas with vim keys and which-key
|
cargo run --example canvas # the canvas, vim keys, which-key, :commands
|
||||||
cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text
|
cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text
|
||||||
```
|
```
|
||||||
+194
-7
@@ -1,6 +1,6 @@
|
|||||||
//! A playground for the canvas (stages 2 and 3 of the build): a sample
|
//! A playground for the canvas (stages 2 to 4 of the build): a sample
|
||||||
//! document to move around, zoom, fold and rearrange, with the real vim keys
|
//! document to move around, zoom, fold and rearrange, with the real vim keys,
|
||||||
//! and the which-key popup. The command line, text editing and the other
|
//! the which-key popup and the `:` command line. Text editing and the other
|
||||||
//! views come in later stages.
|
//! views come in later stages.
|
||||||
//!
|
//!
|
||||||
//! Run it with `cargo run --example canvas`. A few keys to start with:
|
//! Run it with `cargo run --example canvas`. A few keys to start with:
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
//! | `M` | move mode: hjkl moves the branch, Enter ends |
|
//! | `M` | move mode: hjkl moves the branch, Enter ends |
|
||||||
//! | `u` `Ctrl-r` | undo / redo |
|
//! | `u` `Ctrl-r` | undo / redo |
|
||||||
//! | `<Space>` | the action menu (`<Space>w`: key hints on or off) |
|
//! | `<Space>` | the action menu (`<Space>w`: key hints on or off) |
|
||||||
|
//! | `:` | the command line: try `:fold auto`, `:color teal`, `:in an idea`, `:link rain`, `:find soil` (Tab completes) |
|
||||||
//! | `q`, `ZZ` | quit |
|
//! | `q`, `ZZ` | quit |
|
||||||
//!
|
//!
|
||||||
//! Mouse: click selects, dragging a node moves its branch, dragging empty
|
//! Mouse: click selects, dragging a node moves its branch, dragging empty
|
||||||
@@ -24,6 +25,8 @@
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use cumulus::command::history::History as CommandHistory;
|
||||||
|
use cumulus::command::parse::{Call, Sources, TagChange, parse};
|
||||||
use cumulus::input::bindings::Context;
|
use cumulus::input::bindings::Context;
|
||||||
use cumulus::input::command::{Bound, Command, Motion, Operator, Target, help_for};
|
use cumulus::input::command::{Bound, Command, Motion, Operator, Target, help_for};
|
||||||
use cumulus::input::engine::{KeyEngine, Outcome, Typed};
|
use cumulus::input::engine::{KeyEngine, Outcome, Typed};
|
||||||
@@ -31,15 +34,16 @@ use cumulus::input::key::Key;
|
|||||||
use cumulus::input::keymap::Keymap;
|
use cumulus::input::keymap::Keymap;
|
||||||
use cumulus::input::mouse::{Cell, Gesture, MouseReader};
|
use cumulus::input::mouse::{Cell, Gesture, MouseReader};
|
||||||
use cumulus::model::{
|
use cumulus::model::{
|
||||||
CanvasOffset, Change, Direction, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title,
|
CanvasOffset, CanvasPos, CanvasSize, Change, Content, Direction, Document, Edit, EditError, Fold,
|
||||||
Visible,
|
History, Link, LinkKey, Node, NodeId, Spot, Title, Visible,
|
||||||
};
|
};
|
||||||
use cumulus::storage::document_file;
|
use cumulus::storage::document_file;
|
||||||
|
use cumulus::ui::command_line::{CommandLine, LineOutcome};
|
||||||
use cumulus::ui::theme::Theme;
|
use cumulus::ui::theme::Theme;
|
||||||
use cumulus::ui::which_key::WhichKey;
|
use cumulus::ui::which_key::WhichKey;
|
||||||
use cumulus::view::canvas::boxes::{NodeBox, box_rects};
|
use cumulus::view::canvas::boxes::{NodeBox, box_rects};
|
||||||
use cumulus::view::canvas::navigate::nearest;
|
use cumulus::view::canvas::navigate::nearest;
|
||||||
use cumulus::view::canvas::placement::{below_spot, child_spot};
|
use cumulus::view::canvas::placement::{below_spot, child_spot, free_spot};
|
||||||
use cumulus::view::canvas::{Camera, Scene, SceneOptions, ScreenPos};
|
use cumulus::view::canvas::{Camera, Scene, SceneOptions, ScreenPos};
|
||||||
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
|
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
|
||||||
use ratatui::crossterm::execute;
|
use ratatui::crossterm::execute;
|
||||||
@@ -111,6 +115,9 @@ struct Playground {
|
|||||||
mode: Mode,
|
mode: Mode,
|
||||||
mouse: MouseReader,
|
mouse: MouseReader,
|
||||||
drag: Option<Drag>,
|
drag: Option<Drag>,
|
||||||
|
/// The command line, while it's open.
|
||||||
|
command_line: Option<CommandLine>,
|
||||||
|
command_history: CommandHistory,
|
||||||
/// The last frame drawn, to match clicks against.
|
/// The last frame drawn, to match clicks against.
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
canvas_area: Rect,
|
canvas_area: Rect,
|
||||||
@@ -135,6 +142,8 @@ impl Playground {
|
|||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
mouse: MouseReader::default(),
|
mouse: MouseReader::default(),
|
||||||
drag: None,
|
drag: None,
|
||||||
|
command_line: None,
|
||||||
|
command_history: CommandHistory::default(),
|
||||||
scene: Scene::default(),
|
scene: Scene::default(),
|
||||||
canvas_area: Rect::default(),
|
canvas_area: Rect::default(),
|
||||||
follow: true,
|
follow: true,
|
||||||
@@ -187,6 +196,11 @@ impl Playground {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
frame.render_widget(&self.scene, canvas);
|
frame.render_widget(&self.scene, canvas);
|
||||||
|
// While the command line is open, it takes the status line's place.
|
||||||
|
if let Some(line) = &self.command_line {
|
||||||
|
line.render(frame, status, canvas, &self.theme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.which_key_due()
|
if self.which_key_due()
|
||||||
&& let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts()))
|
&& let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts()))
|
||||||
{
|
{
|
||||||
@@ -205,7 +219,7 @@ impl Playground {
|
|||||||
let (mode, hints) = match self.mode {
|
let (mode, hints) = match self.mode {
|
||||||
Mode::Normal => (
|
Mode::Normal => (
|
||||||
" NORMAL ",
|
" NORMAL ",
|
||||||
"hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · <Space> menu · q quit",
|
"hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · : command · <Space> menu · q quit",
|
||||||
),
|
),
|
||||||
Mode::Move { whole: true, .. } => {
|
Mode::Move { whole: true, .. } => {
|
||||||
(" MOVE ", "hjkl moves the branch (HJKL by 5) · M just this node · Enter done")
|
(" MOVE ", "hjkl moves the branch (HJKL by 5) · M just this node · Enter done")
|
||||||
@@ -248,6 +262,9 @@ impl Playground {
|
|||||||
/// Handles a key press. Returns false to quit.
|
/// Handles a key press. Returns false to quit.
|
||||||
fn key(&mut self, key: Key) -> bool {
|
fn key(&mut self, key: Key) -> bool {
|
||||||
self.message.clear();
|
self.message.clear();
|
||||||
|
if self.command_line.is_some() {
|
||||||
|
return self.command_line_key(key);
|
||||||
|
}
|
||||||
let outcome = self.keys.press(key, &self.keymap.tables(self.contexts()));
|
let outcome = self.keys.press(key, &self.keymap.tables(self.contexts()));
|
||||||
match outcome {
|
match outcome {
|
||||||
Outcome::Run(typed) => return self.run_command(typed),
|
Outcome::Run(typed) => return self.run_command(typed),
|
||||||
@@ -295,6 +312,9 @@ impl Playground {
|
|||||||
Command::HalfPageDown => self.camera.pan(0, i32::from(self.canvas_area.height / 2)),
|
Command::HalfPageDown => self.camera.pan(0, i32::from(self.canvas_area.height / 2)),
|
||||||
Command::HalfPageUp => self.camera.pan(0, -i32::from(self.canvas_area.height / 2)),
|
Command::HalfPageUp => self.camera.pan(0, -i32::from(self.canvas_area.height / 2)),
|
||||||
Command::MoveMode => self.mode = Mode::Move { whole: true, change: None },
|
Command::MoveMode => self.mode = Mode::Move { whole: true, change: None },
|
||||||
|
Command::CommandLine => {
|
||||||
|
self.command_line = Some(CommandLine::new(&self.sources(), &self.command_history));
|
||||||
|
}
|
||||||
Command::ToggleWhichKey => {
|
Command::ToggleWhichKey => {
|
||||||
self.which_key = !self.which_key;
|
self.which_key = !self.which_key;
|
||||||
self.message = format!("key hints {}", if self.which_key { "on" } else { "off" });
|
self.message = format!("key hints {}", if self.which_key { "on" } else { "off" });
|
||||||
@@ -379,6 +399,173 @@ impl Playground {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- The command line -----------------------------------------------
|
||||||
|
|
||||||
|
/// A key while the command line is open. Returns false to quit.
|
||||||
|
fn command_line_key(&mut self, key: Key) -> bool {
|
||||||
|
let sources = self.sources();
|
||||||
|
let Some(line) = &mut self.command_line else { return true };
|
||||||
|
match line.key(key, &sources, &self.command_history) {
|
||||||
|
LineOutcome::Typing => true,
|
||||||
|
LineOutcome::Close => {
|
||||||
|
self.command_line = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
LineOutcome::Run(text) => {
|
||||||
|
self.command_line = None;
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.command_history.add(&text);
|
||||||
|
match parse(&text, &sources) {
|
||||||
|
Ok(call) => self.run_call(call),
|
||||||
|
Err(error) => {
|
||||||
|
self.message = error.to_string();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the command line can name: this document, its nodes, the
|
||||||
|
/// palette's colors, its tags.
|
||||||
|
fn sources(&self) -> Sources {
|
||||||
|
let doc = &self.doc;
|
||||||
|
let nodes = doc.outline().into_iter().filter_map(|id| Some((id, doc.node(id)?.title().to_string())));
|
||||||
|
let mut tags: Vec<String> = doc
|
||||||
|
.outline()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|id| doc.node(id))
|
||||||
|
.flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::<Vec<_>>())
|
||||||
|
.collect();
|
||||||
|
tags.sort();
|
||||||
|
tags.dedup();
|
||||||
|
Sources {
|
||||||
|
documents: vec![doc.title().to_string()],
|
||||||
|
nodes: nodes.collect(),
|
||||||
|
colors: self.theme.palette.keys().map(ToString::to_string).collect(),
|
||||||
|
styles: self.theme.node_styles.keys().map(ToString::to_string).collect(),
|
||||||
|
themes: vec!["default".into()],
|
||||||
|
settings: Vec::new(),
|
||||||
|
tags,
|
||||||
|
folder: std::env::current_dir().unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a command line. Returns false to quit.
|
||||||
|
fn run_call(&mut self, call: Call) -> bool {
|
||||||
|
let id = self.cursor;
|
||||||
|
match call {
|
||||||
|
Call::Quit { .. } | Call::QuitAll { .. } | Call::WriteQuit | Call::ClosePane => return false,
|
||||||
|
Call::Fold(fold) => self.set_fold(fold),
|
||||||
|
Call::Focus => {
|
||||||
|
self.options.focus = !self.options.focus;
|
||||||
|
self.message = format!("focus {}", if self.options.focus { "on" } else { "off" });
|
||||||
|
}
|
||||||
|
Call::Color(color) => {
|
||||||
|
self.edit(|edit| edit.update(id, |node| node.look.color = color));
|
||||||
|
}
|
||||||
|
Call::Border(border) => {
|
||||||
|
self.edit(|edit| edit.update(id, |node| node.look.border = Some(border)));
|
||||||
|
}
|
||||||
|
Call::Tags(changes) => {
|
||||||
|
self.edit(|edit| {
|
||||||
|
edit.update(id, |node| {
|
||||||
|
for change in changes {
|
||||||
|
match change {
|
||||||
|
TagChange::Add(tag) => node.tags.insert(tag),
|
||||||
|
TagChange::Remove(tag) => node.tags.remove(&tag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Call::Rename(title) => {
|
||||||
|
let root = self.doc.root();
|
||||||
|
self.edit(|edit| {
|
||||||
|
edit.update(root, |node| {
|
||||||
|
if let Content::Idea(idea) = &mut node.content {
|
||||||
|
idea.title = title;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Call::Capture(text) => self.capture(&text),
|
||||||
|
Call::Link(target) => match Link::new(id, target) {
|
||||||
|
Some(link) => {
|
||||||
|
self.edit(|edit| edit.link(link));
|
||||||
|
}
|
||||||
|
None => self.message = "a node can't link to itself".into(),
|
||||||
|
},
|
||||||
|
Call::Unlink(target) => {
|
||||||
|
let keys: Vec<LinkKey> = match target.and_then(|target| LinkKey::new(id, target)) {
|
||||||
|
Some(key) => vec![key],
|
||||||
|
None => self.doc.links_of(id).map(Link::key).collect(),
|
||||||
|
};
|
||||||
|
self.edit(|edit| keys.iter().try_for_each(|&key| edit.unlink(key).map(drop)));
|
||||||
|
}
|
||||||
|
Call::Find(text) => self.find(&text),
|
||||||
|
Call::Version => self.message = cumulus::version(),
|
||||||
|
Call::Help(_) => {
|
||||||
|
self.message = "the help screen comes in stage 7; Tab completes any command".into()
|
||||||
|
}
|
||||||
|
other => self.message = format!("{other:?} comes in a later stage of the build"),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:in`: adds an idea under the Inbox, a loose node made the first time.
|
||||||
|
fn capture(&mut self, text: &str) {
|
||||||
|
let Ok(title) = Title::parse(text) else { return };
|
||||||
|
let inbox = self
|
||||||
|
.doc
|
||||||
|
.loose()
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|&id| self.doc.node(id).is_some_and(|node| node.title().as_str() == "Inbox"));
|
||||||
|
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
|
||||||
|
let below_everything = rects.values().map(|rect| rect.bottom()).max().unwrap_or(0) + 2;
|
||||||
|
let size = CanvasSize::new(30, 3);
|
||||||
|
let added = self.edit(|edit| {
|
||||||
|
let inbox = match inbox {
|
||||||
|
Some(inbox) => inbox,
|
||||||
|
None => {
|
||||||
|
let pos = free_spot(CanvasPos::new(0, below_everything), size, &rects);
|
||||||
|
let title = Title::parse("Inbox").map_err(|_| EditError::Full)?;
|
||||||
|
edit.add(Node::idea(title, pos), Spot::Loose)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pos = child_spot(edit.doc(), inbox, size, &rects);
|
||||||
|
edit.add(Node::idea(title, pos), Spot::LastChildOf(inbox))
|
||||||
|
});
|
||||||
|
if added.is_some() {
|
||||||
|
self.message = format!("captured \"{text}\" to the Inbox");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:find`: goes to the next node whose title has the text in it.
|
||||||
|
fn find(&mut self, text: &str) {
|
||||||
|
let wanted = text.to_lowercase();
|
||||||
|
let order = self.doc.outline();
|
||||||
|
let here = order.iter().position(|&id| id == self.cursor).unwrap_or(0);
|
||||||
|
let matches: Vec<NodeId> = order
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&id| {
|
||||||
|
self.doc.node(id).is_some_and(|node| node.title().as_str().to_lowercase().contains(&wanted))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let next = matches.iter().copied().find(|&id| order.iter().position(|&o| o == id) > Some(here));
|
||||||
|
match next.or_else(|| matches.first().copied()) {
|
||||||
|
Some(id) => {
|
||||||
|
self.jump(Some(id));
|
||||||
|
self.message = format!("{} match(es) for \"{text}\"", matches.len());
|
||||||
|
}
|
||||||
|
None => self.message = format!("nothing matches \"{text}\""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----- The mouse ------------------------------------------------------
|
// ----- The mouse ------------------------------------------------------
|
||||||
|
|
||||||
fn gesture(&mut self, gesture: Gesture) {
|
fn gesture(&mut self, gesture: Gesture) {
|
||||||
|
|||||||
@@ -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 |
|
//! | [`model`] | the data and its rules: documents, nodes, links, undo, folds |
|
||||||
//! | [`input`] | keys and the mouse: vim keys, which-key, keys.toml |
|
//! | [`input`] | keys and the mouse: vim keys, which-key, keys.toml |
|
||||||
|
//! | [`command`] | the `:` command line: commands, parsing, completion, history |
|
||||||
//! | [`storage`] | files: documents (RON) |
|
//! | [`storage`] | files: documents (RON) |
|
||||||
//! | [`view`] | the views, one module each: so far, the canvas |
|
//! | [`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 input;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod storage;
|
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
|
//! What the views share: the characters they draw with ([`glyphs`]), the
|
||||||
//! colors and looks ([`theme`]) and the which-key popup ([`which_key`]). The
|
//! colors and looks ([`theme`]), the which-key popup ([`which_key`]), and the
|
||||||
//! command line, the bars and the other shared widgets come in later stages.
|
//! 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 glyphs;
|
||||||
|
pub mod line_input;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod which_key;
|
pub mod which_key;
|
||||||
@@ -2,3 +2,30 @@
|
|||||||
//! all share; for now there's the canvas.
|
//! all share; for now there's the canvas.
|
||||||
|
|
||||||
pub mod 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