Add the command line: commands, completion, ghost text and history

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 03:53:51 -04:00
1 parent c2e62de172
commit bfc184f5ef
14 files changed
+2094 -11

No files matched your search

+194 -7
View File
@@ -1,6 +1,6 @@
//! A playground for the canvas (stages 2 and 3 of the build): a sample
//! 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
//! 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,
//! the which-key popup and the `:` command line. Text editing and the other
//! views come in later stages.
//!
//! 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 |
//! | `u` `Ctrl-r` | undo / redo |
//! | `<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 |
//!
//! Mouse: click selects, dragging a node moves its branch, dragging empty
@@ -24,6 +25,8 @@
use std::io;
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::command::{Bound, Command, Motion, Operator, Target, help_for};
use cumulus::input::engine::{KeyEngine, Outcome, Typed};
@@ -31,15 +34,16 @@ use cumulus::input::key::Key;
use cumulus::input::keymap::Keymap;
use cumulus::input::mouse::{Cell, Gesture, MouseReader};
use cumulus::model::{
CanvasOffset, Change, Direction, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title,
Visible,
CanvasOffset, CanvasPos, CanvasSize, Change, Content, Direction, Document, Edit, EditError, Fold,
History, Link, LinkKey, Node, NodeId, Spot, Title, Visible,
};
use cumulus::storage::document_file;
use cumulus::ui::command_line::{CommandLine, LineOutcome};
use cumulus::ui::theme::Theme;
use cumulus::ui::which_key::WhichKey;
use cumulus::view::canvas::boxes::{NodeBox, box_rects};
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 ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
use ratatui::crossterm::execute;
@@ -111,6 +115,9 @@ struct Playground {
mode: Mode,
mouse: MouseReader,
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.
scene: Scene,
canvas_area: Rect,
@@ -135,6 +142,8 @@ impl Playground {
mode: Mode::Normal,
mouse: MouseReader::default(),
drag: None,
command_line: None,
command_history: CommandHistory::default(),
scene: Scene::default(),
canvas_area: Rect::default(),
follow: true,
@@ -187,6 +196,11 @@ impl Playground {
}
}
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()
&& let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts()))
{
@@ -205,7 +219,7 @@ impl Playground {
let (mode, hints) = match self.mode {
Mode::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, .. } => {
(" 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.
fn key(&mut self, key: Key) -> bool {
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()));
match outcome {
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::HalfPageUp => self.camera.pan(0, -i32::from(self.canvas_area.height / 2)),
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 => {
self.which_key = !self.which_key;
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 ------------------------------------------------------
fn gesture(&mut self, gesture: Gesture) {