Make marks and counts work, and the completion popup a setting
Marks: the engine read `ma` and `'a`, but the canvas example only said they'd come later. It now sets and jumps to marks, and `''` goes back to where the cursor was before the last jump (a mark, gg, G or :find). Which-key lists `'` after `'`. Counts on operators now mean something on nodes: `3dd` deletes this node and the next two siblings, with their branches, the way vim's `3dd` deletes three lines (Document::siblings_from). Counts before the operator and before the target still multiply, and which-key now says what they add up to: after `2d3`, the `d` entry reads "6 nodes: this one and the next 5 siblings, with their branches". The completion popup can now wait for Tab, like vim's wildmenu: the `completion_popup` setting is "as-you-type" (the default) or "on-tab". The config file comes with the app core; until then the example takes `:set completion_popup=on-tab`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
bfc184f5ef
commit
57ba2c275d
6 files changed
+199
-35
No files matched your search
@@ -87,6 +87,9 @@ lines.
|
|||||||
- Tab and `Ctrl-i` jump forward, as in vim, so `a` adds a child.
|
- Tab and `Ctrl-i` jump forward, as in vim, so `a` adds a child.
|
||||||
- `dd` deletes a node with its branch; `x` deletes just the node, and its
|
- `dd` deletes a node with its branch; `x` deletes just the node, and its
|
||||||
children move up.
|
children move up.
|
||||||
|
- A count on `dd`, `yy`, `>>` or `<<` takes in the next siblings: `3dd`
|
||||||
|
deletes this node and the next two, with their branches, as vim's `3dd`
|
||||||
|
takes three lines. Which-key says how many as you type (`2d3`: 6 nodes).
|
||||||
- Rapid entry is what happens after `o`, `O` or `a`: Enter starts the next
|
- Rapid entry is what happens after `o`, `O` or `a`: Enter starts the next
|
||||||
sibling, Tab / Shift-Tab indent and outdent it, Enter on an empty line stops.
|
sibling, Tab / Shift-Tab indent and outdent it, Enter on an empty line stops.
|
||||||
Esc switches to vim motions inside the text; a second Esc leaves.
|
Esc switches to vim motions inside the text; a second Esc leaves.
|
||||||
@@ -334,6 +337,7 @@ which_key = true
|
|||||||
which_key_delay = "300ms"
|
which_key_delay = "300ms"
|
||||||
max_node_width = 30
|
max_node_width = 30
|
||||||
glyphs = "unicode" # "ascii" for fonts without box-drawing characters
|
glyphs = "unicode" # "ascii" for fonts without box-drawing characters
|
||||||
|
completion_popup = "as-you-type" # "on-tab": only after Tab, like vim's wildmenu
|
||||||
|
|
||||||
# keys.toml: sections motions, normal, canvas, outline, note, visual, move,
|
# keys.toml: sections motions, normal, canvas, outline, note, visual, move,
|
||||||
# connect, list; commands by name (src/input/command.rs); "nothing" unbinds
|
# connect, list; commands by name (src/input/command.rs); "nothing" unbinds
|
||||||
|
|||||||
+93
-12
@@ -22,11 +22,12 @@
|
|||||||
//! Mouse: click selects, dragging a node moves its branch, dragging empty
|
//! Mouse: click selects, dragging a node moves its branch, dragging empty
|
||||||
//! space pans, the wheel scrolls.
|
//! space pans, the wheel scrolls.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
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::history::History as CommandHistory;
|
||||||
use cumulus::command::parse::{Call, Sources, TagChange, parse};
|
use cumulus::command::parse::{Call, SetArg, 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};
|
||||||
@@ -38,7 +39,7 @@ use cumulus::model::{
|
|||||||
History, Link, LinkKey, Node, NodeId, Spot, Title, 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::command_line::{CommandLine, LineOutcome, PopupMode};
|
||||||
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};
|
||||||
@@ -118,6 +119,12 @@ struct Playground {
|
|||||||
/// The command line, while it's open.
|
/// The command line, while it's open.
|
||||||
command_line: Option<CommandLine>,
|
command_line: Option<CommandLine>,
|
||||||
command_history: CommandHistory,
|
command_history: CommandHistory,
|
||||||
|
/// When the command line's popup shows (`:set completion_popup=on-tab`).
|
||||||
|
popup: PopupMode,
|
||||||
|
/// Marked nodes (`ma`), and where the cursor was before the last jump
|
||||||
|
/// (for `''`).
|
||||||
|
marks: BTreeMap<char, NodeId>,
|
||||||
|
before_jump: Option<NodeId>,
|
||||||
/// 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,
|
||||||
@@ -144,6 +151,9 @@ impl Playground {
|
|||||||
drag: None,
|
drag: None,
|
||||||
command_line: None,
|
command_line: None,
|
||||||
command_history: CommandHistory::default(),
|
command_history: CommandHistory::default(),
|
||||||
|
popup: PopupMode::default(),
|
||||||
|
marks: BTreeMap::new(),
|
||||||
|
before_jump: None,
|
||||||
scene: Scene::default(),
|
scene: Scene::default(),
|
||||||
canvas_area: Rect::default(),
|
canvas_area: Rect::default(),
|
||||||
follow: true,
|
follow: true,
|
||||||
@@ -284,7 +294,24 @@ impl Playground {
|
|||||||
let steps = i32::try_from(times).unwrap_or(1);
|
let steps = i32::try_from(times).unwrap_or(1);
|
||||||
match typed.command {
|
match typed.command {
|
||||||
Command::Move(motion) => (0..times).for_each(|_| self.motion(motion)),
|
Command::Move(motion) => (0..times).for_each(|_| self.motion(motion)),
|
||||||
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => self.delete_branch(),
|
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => {
|
||||||
|
self.delete_branches(times)
|
||||||
|
}
|
||||||
|
Command::SetMark(name) => {
|
||||||
|
self.marks.insert(name, self.cursor);
|
||||||
|
let title =
|
||||||
|
self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default();
|
||||||
|
self.message = format!("mark {name} is on \"{title}\" ('{name} comes back here)");
|
||||||
|
}
|
||||||
|
Command::JumpToMark('\'' | '`') => match self.before_jump {
|
||||||
|
Some(id) if self.doc.contains(id) => self.leap(Some(id)),
|
||||||
|
_ => self.message = "no jump to go back from yet".into(),
|
||||||
|
},
|
||||||
|
Command::JumpToMark(name) => match self.marks.get(&name) {
|
||||||
|
Some(&id) if self.doc.contains(id) => self.leap(Some(id)),
|
||||||
|
Some(_) => self.message = format!("the node marked {name} is gone"),
|
||||||
|
None => self.message = format!("no mark {name} yet: m{name} sets it"),
|
||||||
|
},
|
||||||
Command::Operate(Operator::Delete, Target::Children) => self.delete_children(),
|
Command::Operate(Operator::Delete, Target::Children) => self.delete_children(),
|
||||||
Command::DeleteNode => self.delete_node(),
|
Command::DeleteNode => self.delete_node(),
|
||||||
Command::AddChild => self.add(Spot::LastChildOf(self.cursor)),
|
Command::AddChild => self.add(Spot::LastChildOf(self.cursor)),
|
||||||
@@ -313,7 +340,8 @@ impl Playground {
|
|||||||
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 => {
|
Command::CommandLine => {
|
||||||
self.command_line = Some(CommandLine::new(&self.sources(), &self.command_history));
|
self.command_line =
|
||||||
|
Some(CommandLine::new(&self.sources(), &self.command_history, self.popup));
|
||||||
}
|
}
|
||||||
Command::ToggleWhichKey => {
|
Command::ToggleWhichKey => {
|
||||||
self.which_key = !self.which_key;
|
self.which_key = !self.which_key;
|
||||||
@@ -355,7 +383,12 @@ impl Playground {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.jump(target);
|
// gg and G are jumps, as in vim: '' comes back from them.
|
||||||
|
if matches!(motion, Motion::First | Motion::Last) {
|
||||||
|
self.leap(target);
|
||||||
|
} else {
|
||||||
|
self.jump(target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commands in move mode: hjkl moves the node or its branch.
|
/// Commands in move mode: hjkl moves the node or its branch.
|
||||||
@@ -447,7 +480,7 @@ impl Playground {
|
|||||||
colors: self.theme.palette.keys().map(ToString::to_string).collect(),
|
colors: self.theme.palette.keys().map(ToString::to_string).collect(),
|
||||||
styles: self.theme.node_styles.keys().map(ToString::to_string).collect(),
|
styles: self.theme.node_styles.keys().map(ToString::to_string).collect(),
|
||||||
themes: vec!["default".into()],
|
themes: vec!["default".into()],
|
||||||
settings: Vec::new(),
|
settings: vec!["completion_popup".into()],
|
||||||
tags,
|
tags,
|
||||||
folder: std::env::current_dir().unwrap_or_default(),
|
folder: std::env::current_dir().unwrap_or_default(),
|
||||||
}
|
}
|
||||||
@@ -507,6 +540,26 @@ impl Playground {
|
|||||||
}
|
}
|
||||||
Call::Find(text) => self.find(&text),
|
Call::Find(text) => self.find(&text),
|
||||||
Call::Version => self.message = cumulus::version(),
|
Call::Version => self.message = cumulus::version(),
|
||||||
|
Call::Set(SetArg::Value(name, value)) if name == "completion_popup" => {
|
||||||
|
match PopupMode::named(&value) {
|
||||||
|
Some(popup) => {
|
||||||
|
self.popup = popup;
|
||||||
|
self.message = format!("completion_popup = {value}");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let values: Vec<&str> = PopupMode::VALUES.iter().map(|(name, _)| *name).collect();
|
||||||
|
self.message = format!("completion_popup can be {}", values.join(" or "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Call::Set(SetArg::Reset(name)) if name == "completion_popup" => {
|
||||||
|
self.popup = PopupMode::default();
|
||||||
|
self.message = "completion_popup = as-you-type (the default)".into();
|
||||||
|
}
|
||||||
|
Call::Set(SetArg::Show(name)) if name == "completion_popup" => {
|
||||||
|
let current = PopupMode::VALUES.iter().find(|(_, mode)| *mode == self.popup);
|
||||||
|
self.message = format!("completion_popup = {}", current.map_or("?", |(name, _)| *name));
|
||||||
|
}
|
||||||
Call::Help(_) => {
|
Call::Help(_) => {
|
||||||
self.message = "the help screen comes in stage 7; Tab completes any command".into()
|
self.message = "the help screen comes in stage 7; Tab completes any command".into()
|
||||||
}
|
}
|
||||||
@@ -559,7 +612,7 @@ impl Playground {
|
|||||||
let next = matches.iter().copied().find(|&id| order.iter().position(|&o| o == id) > Some(here));
|
let next = matches.iter().copied().find(|&id| order.iter().position(|&o| o == id) > Some(here));
|
||||||
match next.or_else(|| matches.first().copied()) {
|
match next.or_else(|| matches.first().copied()) {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
self.jump(Some(id));
|
self.leap(Some(id));
|
||||||
self.message = format!("{} match(es) for \"{text}\"", matches.len());
|
self.message = format!("{} match(es) for \"{text}\"", matches.len());
|
||||||
}
|
}
|
||||||
None => self.message = format!("nothing matches \"{text}\""),
|
None => self.message = format!("nothing matches \"{text}\""),
|
||||||
@@ -649,6 +702,14 @@ impl Playground {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A jump `''` can come back from (to a mark, `gg`, `G`, a search).
|
||||||
|
fn leap(&mut self, target: Option<NodeId>) {
|
||||||
|
if target.is_some() {
|
||||||
|
self.before_jump = Some(self.cursor);
|
||||||
|
}
|
||||||
|
self.jump(target);
|
||||||
|
}
|
||||||
|
|
||||||
fn zoom(&mut self, closer: bool) {
|
fn zoom(&mut self, closer: bool) {
|
||||||
let zoom = if closer { self.camera.zoom.closer() } else { self.camera.zoom.further() };
|
let zoom = if closer { self.camera.zoom.closer() } else { self.camera.zoom.further() };
|
||||||
if let Some(node) = self.doc.node(self.cursor) {
|
if let Some(node) = self.doc.node(self.cursor) {
|
||||||
@@ -707,12 +768,32 @@ impl Playground {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_branch(&mut self) {
|
/// `dd` (or `dab`), with a count: this node and the next siblings, each
|
||||||
let id = self.cursor;
|
/// with its branch, as `3dd` deletes three lines in vim.
|
||||||
let next = self.doc.parent(id).or_else(|| self.doc.previous_sibling(id));
|
fn delete_branches(&mut self, count: u32) {
|
||||||
if let Some(removed) = self.edit(|edit| edit.remove_branch(id)) {
|
let branches = self.doc.siblings_from(self.cursor, usize::try_from(count).unwrap_or(1));
|
||||||
|
let Some(&last) = branches.last() else { return };
|
||||||
|
// The cursor goes to the sibling after them, or before them, or up.
|
||||||
|
let next = self
|
||||||
|
.doc
|
||||||
|
.next_sibling(last)
|
||||||
|
.or_else(|| self.doc.previous_sibling(self.cursor))
|
||||||
|
.or_else(|| self.doc.parent(self.cursor));
|
||||||
|
let removed = self.edit(|edit| {
|
||||||
|
let mut removed = 0;
|
||||||
|
for &branch in &branches {
|
||||||
|
removed += edit.remove_branch(branch)?.len();
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
});
|
||||||
|
if let Some(removed) = removed {
|
||||||
self.cursor = next.unwrap_or(self.doc.root());
|
self.cursor = next.unwrap_or(self.doc.root());
|
||||||
self.message = format!("deleted {} node(s) · u to undo", removed.len());
|
let what = if branches.len() == 1 {
|
||||||
|
"1 branch".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} branches", branches.len())
|
||||||
|
};
|
||||||
|
self.message = format!("deleted {what}, {removed} node(s) in all · u to undo");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ impl CharKey {
|
|||||||
pub fn hints(self) -> &'static [(&'static str, &'static str)] {
|
pub fn hints(self) -> &'static [(&'static str, &'static str)] {
|
||||||
match self {
|
match self {
|
||||||
CharKey::SetMark => &[("a–z", "mark this node"), ("A–Z", "mark it, across documents")],
|
CharKey::SetMark => &[("a–z", "mark this node"), ("A–Z", "mark it, across documents")],
|
||||||
CharKey::JumpToMark => &[("a–z A–Z", "go to that mark")],
|
CharKey::JumpToMark => {
|
||||||
|
&[("a–z A–Z", "go to that mark"), ("'", "back to where you were before the last jump")]
|
||||||
|
}
|
||||||
_ => &[("any key", "the character to go to")],
|
_ => &[("any key", "the character to go to")],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -186,10 +186,19 @@ impl KeyEngine {
|
|||||||
let entries = next_keys(&self.keys, &tables.commands_starting_with(&self.keys), tables);
|
let entries = next_keys(&self.keys, &tables.commands_starting_with(&self.keys), tables);
|
||||||
Some(Hints { title, entries })
|
Some(Hints { title, entries })
|
||||||
}
|
}
|
||||||
Stage::Target { operator, operator_keys, .. } => {
|
Stage::Target { operator, operator_keys, count } => {
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
if self.keys.is_empty() {
|
if self.keys.is_empty() {
|
||||||
let help = "this node, with its branch".to_string();
|
// A count takes in the next siblings, as `3dd` takes three
|
||||||
|
// lines in vim: say exactly how many.
|
||||||
|
let help = match multiply(*count, self.count).unwrap_or(1) {
|
||||||
|
1 => "this node, with its branch".to_string(),
|
||||||
|
2 => "2 nodes: this one and the next sibling, with their branches".to_string(),
|
||||||
|
many => format!(
|
||||||
|
"{many} nodes: this one and the next {} siblings, with their branches",
|
||||||
|
many - 1
|
||||||
|
),
|
||||||
|
};
|
||||||
entries.push(Hint { keys: notation(operator_keys), help, group: false });
|
entries.push(Hint { keys: notation(operator_keys), help, group: false });
|
||||||
}
|
}
|
||||||
let targets = tables.targets_starting_with(&self.keys);
|
let targets = tables.targets_starting_with(&self.keys);
|
||||||
@@ -430,6 +439,12 @@ mod tests {
|
|||||||
assert_eq!(keys, ["d", "a", "i"]);
|
assert_eq!(keys, ["d", "a", "i"]);
|
||||||
assert!(hints.entries[1].group);
|
assert!(hints.entries[1].group);
|
||||||
|
|
||||||
|
engine.reset();
|
||||||
|
type_keys(&mut engine, &keymap, "2d3");
|
||||||
|
let hints = engine.hints(&tables).unwrap();
|
||||||
|
assert_eq!(hints.title, "2d3 · delete");
|
||||||
|
assert_eq!(hints.entries[0].help, "6 nodes: this one and the next 5 siblings, with their branches");
|
||||||
|
|
||||||
engine.reset();
|
engine.reset();
|
||||||
type_keys(&mut engine, &keymap, "\"");
|
type_keys(&mut engine, &keymap, "\"");
|
||||||
assert_eq!(engine.hints(&tables).unwrap().title, "\" · register");
|
assert_eq!(engine.hints(&tables).unwrap().title, "\" · register");
|
||||||
|
|||||||
@@ -281,6 +281,14 @@ impl Document {
|
|||||||
siblings.get(index.checked_sub(1)?).copied()
|
siblings.get(index.checked_sub(1)?).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `id` and the siblings after it, `count` nodes in all (fewer at the end
|
||||||
|
/// of the row): what a count on an operator acts on, as `3dd` deletes
|
||||||
|
/// three lines in vim.
|
||||||
|
pub fn siblings_from(&self, id: NodeId, count: usize) -> Vec<NodeId> {
|
||||||
|
let Some((siblings, index)) = self.siblings(id) else { return Vec::new() };
|
||||||
|
siblings.into_iter().skip(index).take(count.max(1)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The row of siblings `id` is in, and its index there.
|
/// The row of siblings `id` is in, and its index there.
|
||||||
fn siblings(&self, id: NodeId) -> Option<(Vec<NodeId>, usize)> {
|
fn siblings(&self, id: NodeId) -> Option<(Vec<NodeId>, usize)> {
|
||||||
let siblings: Vec<NodeId> = match self.place(id)? {
|
let siblings: Vec<NodeId> = match self.place(id)? {
|
||||||
@@ -486,6 +494,9 @@ mod tests {
|
|||||||
assert!(doc.is_inside(g.cedar, g.root) && !doc.is_inside(g.root, g.cedar));
|
assert!(doc.is_inside(g.cedar, g.root) && !doc.is_inside(g.root, g.cedar));
|
||||||
assert_eq!(doc.depth(g.cedar), 2);
|
assert_eq!(doc.depth(g.cedar), 2);
|
||||||
assert_eq!(doc.branch(g.beds), vec![g.beds, g.cedar]);
|
assert_eq!(doc.branch(g.beds), vec![g.beds, g.cedar]);
|
||||||
|
assert_eq!(doc.siblings_from(g.soil, 2), vec![g.soil, g.beds]);
|
||||||
|
assert_eq!(doc.siblings_from(g.beds, 9), vec![g.beds, g.watering], "fewer at the end of the row");
|
||||||
|
assert_eq!(doc.siblings_from(g.cedar, 0), vec![g.cedar], "at least the node itself");
|
||||||
assert_eq!(doc.outline(), vec![g.root, g.soil, g.beds, g.cedar, g.watering, g.compost]);
|
assert_eq!(doc.outline(), vec![g.root, g.soil, g.beds, g.cedar, g.watering, g.compost]);
|
||||||
assert_eq!(Document::new(title("x")).node_count(), 1);
|
assert_eq!(Document::new(title("x")).node_count(), 1);
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-20
@@ -31,9 +31,31 @@ use crate::input::key::{Key, KeyName};
|
|||||||
/// How many candidates the popup shows at once.
|
/// How many candidates the popup shows at once.
|
||||||
const POPUP_ROWS: usize = 8;
|
const POPUP_ROWS: usize = 8;
|
||||||
|
|
||||||
|
/// When the completion popup shows (the `completion_popup` setting).
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum PopupMode {
|
||||||
|
/// As soon as there's something to complete.
|
||||||
|
#[default]
|
||||||
|
AsYouType,
|
||||||
|
/// Only after Tab, like vim's wildmenu.
|
||||||
|
OnTab,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PopupMode {
|
||||||
|
/// The setting's values, as written in the config.
|
||||||
|
pub const VALUES: [(&str, PopupMode); 2] =
|
||||||
|
[("as-you-type", PopupMode::AsYouType), ("on-tab", PopupMode::OnTab)];
|
||||||
|
|
||||||
|
/// The mode a config value names.
|
||||||
|
pub fn named(value: &str) -> Option<PopupMode> {
|
||||||
|
PopupMode::VALUES.iter().find(|(name, _)| *name == value).map(|(_, mode)| *mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The command line while it's open.
|
/// The command line while it's open.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct CommandLine {
|
pub struct CommandLine {
|
||||||
|
popup: PopupMode,
|
||||||
input: LineInput,
|
input: LineInput,
|
||||||
completion: Completion,
|
completion: Completion,
|
||||||
/// The candidate Tab put in the line, while Tab cycles through them.
|
/// The candidate Tab put in the line, while Tab cycles through them.
|
||||||
@@ -57,9 +79,9 @@ pub enum LineOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommandLine {
|
impl CommandLine {
|
||||||
/// An empty command line.
|
/// An empty command line, with its popup showing as `popup` says.
|
||||||
pub fn new(sources: &Sources, history: &History) -> CommandLine {
|
pub fn new(sources: &Sources, history: &History, popup: PopupMode) -> CommandLine {
|
||||||
let mut line = CommandLine::default();
|
let mut line = CommandLine { popup, ..CommandLine::default() };
|
||||||
line.changed(sources, history);
|
line.changed(sources, history);
|
||||||
line
|
line
|
||||||
}
|
}
|
||||||
@@ -186,7 +208,10 @@ impl CommandLine {
|
|||||||
/// The candidates, in a box above the word they'd finish.
|
/// The candidates, in a box above the word they'd finish.
|
||||||
fn render_popup(&self, frame: &mut Frame, line: Rect, above: Rect, theme: &Theme) {
|
fn render_popup(&self, frame: &mut Frame, line: Rect, above: Rect, theme: &Theme) {
|
||||||
let candidates = &self.completion.candidates;
|
let candidates = &self.completion.candidates;
|
||||||
let wanted = !self.input.text().is_empty() || self.selected.is_some();
|
let wanted = match self.popup {
|
||||||
|
PopupMode::AsYouType => !self.input.text().is_empty() || self.selected.is_some(),
|
||||||
|
PopupMode::OnTab => self.selected.is_some(),
|
||||||
|
};
|
||||||
if candidates.is_empty() || !wanted || above.height < 3 {
|
if candidates.is_empty() || !wanted || above.height < 3 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -243,14 +268,12 @@ mod tests {
|
|||||||
Sources { colors: vec!["red".into(), "orange".into()], ..Sources::default() }
|
Sources { colors: vec!["red".into(), "orange".into()], ..Sources::default() }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// Draws the command line at the bottom of a 60×8 screen: its rows, and
|
||||||
fn the_popup_sits_above_the_word_it_finishes() {
|
/// where the cursor ended up.
|
||||||
|
fn draw(line: &CommandLine) -> (Vec<String>, Position) {
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::TestBackend;
|
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();
|
let mut terminal = Terminal::new(TestBackend::new(60, 8)).unwrap();
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
@@ -260,12 +283,40 @@ mod tests {
|
|||||||
line.render(frame, bottom, above, &Theme::default());
|
line.render(frame, bottom, above, &Theme::default());
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer().clone();
|
let buffer = terminal.backend().buffer();
|
||||||
let row = |y: u16| (0..60).map(|x| buffer[(x, y)].symbol().to_string()).collect::<String>();
|
let rows = (0..8)
|
||||||
assert_eq!(row(4).trim_end(), " ╭──────────────────────────────────────╮");
|
.map(|y| {
|
||||||
assert_eq!(row(5).trim_end(), " │ auto shows them while you're inside │");
|
(0..60)
|
||||||
assert_eq!(row(7).trim_end(), ":fold auto", "\"uto\" is the ghost text");
|
.map(|x| buffer[(x, y)].symbol().to_string())
|
||||||
assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(7, 7));
|
.collect::<String>()
|
||||||
|
.trim_end()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(rows, terminal.get_cursor_position().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_popup_sits_above_the_word_it_finishes() {
|
||||||
|
let history = History::default();
|
||||||
|
let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType);
|
||||||
|
press(&mut line, "fold a", &history);
|
||||||
|
let (rows, cursor) = draw(&line);
|
||||||
|
assert_eq!(rows[4], " ╭──────────────────────────────────────╮");
|
||||||
|
assert_eq!(rows[5], " │ auto shows them while you're inside │");
|
||||||
|
assert_eq!(rows[7], ":fold auto", "\"uto\" is the ghost text");
|
||||||
|
assert_eq!(cursor, Position::new(7, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn on_tab_the_popup_waits_for_tab() {
|
||||||
|
let history = History::default();
|
||||||
|
let mut line = CommandLine::new(&sources(), &history, PopupMode::OnTab);
|
||||||
|
press(&mut line, "fold a", &history);
|
||||||
|
assert!(draw(&line).0[..7].iter().all(String::is_empty), "no popup yet");
|
||||||
|
press(&mut line, "<Tab>", &history);
|
||||||
|
assert_eq!(draw(&line).0[5], " │ auto shows them while you're inside │");
|
||||||
|
assert_eq!(PopupMode::named("on-tab"), Some(PopupMode::OnTab));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn press(line: &mut CommandLine, keys: &str, history: &History) -> LineOutcome {
|
fn press(line: &mut CommandLine, keys: &str, history: &History) -> LineOutcome {
|
||||||
@@ -279,7 +330,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tab_cycles_through_the_candidates() {
|
fn tab_cycles_through_the_candidates() {
|
||||||
let history = History::default();
|
let history = History::default();
|
||||||
let mut line = CommandLine::new(&sources(), &history);
|
let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType);
|
||||||
press(&mut line, "fo", &history);
|
press(&mut line, "fo", &history);
|
||||||
press(&mut line, "<Tab>", &history);
|
press(&mut line, "<Tab>", &history);
|
||||||
assert_eq!(line.text(), "fold");
|
assert_eq!(line.text(), "fold");
|
||||||
@@ -293,12 +344,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ghost_text_comes_from_history_first() {
|
fn ghost_text_comes_from_history_first() {
|
||||||
let history = History::from_lines(vec!["color orange".into()]);
|
let history = History::from_lines(vec!["color orange".into()]);
|
||||||
let mut line = CommandLine::new(&sources(), &history);
|
let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType);
|
||||||
press(&mut line, "color o", &history);
|
press(&mut line, "color o", &history);
|
||||||
assert_eq!(line.ghost.as_deref(), Some("range"));
|
assert_eq!(line.ghost.as_deref(), Some("range"));
|
||||||
press(&mut line, "<Right>", &history);
|
press(&mut line, "<Right>", &history);
|
||||||
assert_eq!(line.text(), "color orange");
|
assert_eq!(line.text(), "color orange");
|
||||||
let mut fresh = CommandLine::new(&sources(), &History::default());
|
let mut fresh = CommandLine::new(&sources(), &History::default(), PopupMode::AsYouType);
|
||||||
press(&mut fresh, "col", &History::default());
|
press(&mut fresh, "col", &History::default());
|
||||||
assert_eq!(fresh.ghost.as_deref(), Some("or"), "no history: the best candidate");
|
assert_eq!(fresh.ghost.as_deref(), Some("or"), "no history: the best candidate");
|
||||||
}
|
}
|
||||||
@@ -306,7 +357,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn up_and_down_browse_matching_lines() {
|
fn up_and_down_browse_matching_lines() {
|
||||||
let history = History::from_lines(vec!["fold open".into(), "q".into(), "fold auto".into()]);
|
let history = History::from_lines(vec!["fold open".into(), "q".into(), "fold auto".into()]);
|
||||||
let mut line = CommandLine::new(&sources(), &history);
|
let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType);
|
||||||
press(&mut line, "fo<Up>", &history);
|
press(&mut line, "fo<Up>", &history);
|
||||||
assert_eq!(line.text(), "fold auto");
|
assert_eq!(line.text(), "fold auto");
|
||||||
press(&mut line, "<Up>", &history);
|
press(&mut line, "<Up>", &history);
|
||||||
@@ -319,7 +370,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn backspace_on_an_empty_line_closes_it() {
|
fn backspace_on_an_empty_line_closes_it() {
|
||||||
let history = History::default();
|
let history = History::default();
|
||||||
let mut line = CommandLine::new(&sources(), &history);
|
let mut line = CommandLine::new(&sources(), &history, PopupMode::AsYouType);
|
||||||
assert_eq!(press(&mut line, "q<BS>", &history), LineOutcome::Typing);
|
assert_eq!(press(&mut line, "q<BS>", &history), LineOutcome::Typing);
|
||||||
assert_eq!(press(&mut line, "<BS>", &history), LineOutcome::Close);
|
assert_eq!(press(&mut line, "<BS>", &history), LineOutcome::Close);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user