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
+194
-7
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user