Add the key engine, which-key and keys.toml
Keys now work the way vim reads them. A key press becomes a Key (with vim notation for writing keys: `<C-w>v`, `<leader>c`, `<<`), and the engine collects counts (`12j`), registers (`"+p`), sequences (`gg`), operators with their targets (`dd`, `dab`, `2d3d` multiplies to 6) and keys that take a character (`ma`, `'A`), then hands over a typed Command. Every command has a name and a help text in one table, and the default keys live in one table per place they work (motions, normal, canvas, visual, move, connect, lists). keys.toml changes keys per section, with the leader configurable and "nothing" to unbind. Each mistake is skipped and reported in words, with a suggestion for mistyped command names, and a key that would hide a longer one is reported too. The which-key popup reads the same tables: it shows what can follow the keys typed so far, in skyshift's look and place, with the leader menu instant and other prefixes after 300 ms. The mouse reader turns button events into presses, double-clicks, drags and scrolls. `Direction` moved from the glyphs to the model, since input needs it too. The canvas example now runs on all of this, with a two-line keys.toml of its own that makes `q` quit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e097e53a85
commit
c2e62de172
21 files changed
+2501
-204
No files matched your search
Generated
+65
@@ -117,6 +117,7 @@ dependencies = [
|
||||
"ron",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"toml",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
@@ -253,6 +254,16 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
@@ -652,6 +663,15 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
@@ -785,6 +805,45 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -862,3 +921,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
@@ -11,6 +11,8 @@ publish = false
|
||||
# Document files are RON (Rust Object Notation), read and written through serde.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
ron = "0.12"
|
||||
# Config files are TOML: keys.toml so far, config.toml and themes later.
|
||||
toml = "1"
|
||||
# Error types with readable messages, for the parts that report problems.
|
||||
thiserror = "2"
|
||||
# Terminal user interface. The crossterm backend works on Windows, Linux and
|
||||
|
||||
@@ -335,10 +335,12 @@ which_key_delay = "300ms"
|
||||
max_node_width = 30
|
||||
glyphs = "unicode" # "ascii" for fonts without box-drawing characters
|
||||
|
||||
# keys.toml
|
||||
# keys.toml: sections motions, normal, canvas, outline, note, visual, move,
|
||||
# connect, list; commands by name (src/input/command.rs); "nothing" unbinds
|
||||
leader = "<Space>"
|
||||
[normal]
|
||||
"<Space>x" = "delete-branch"
|
||||
"x" = "nothing"
|
||||
[canvas]
|
||||
"H" = "previous-document"
|
||||
|
||||
|
||||
@@ -58,6 +58,6 @@ cargo fmt # format the code
|
||||
While the app is being built, the examples try out each part:
|
||||
|
||||
```sh
|
||||
cargo run --example canvas # the canvas, on a sample document
|
||||
cargo run --example canvas # the canvas with vim keys and which-key
|
||||
cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text
|
||||
```
|
||||
+316
-172
@@ -1,50 +1,68 @@
|
||||
//! A playground for the canvas engine (stage 2 of the build): a sample
|
||||
//! document you can move around, zoom, fold and rearrange. The real keys,
|
||||
//! modes and command line come in later stages; this has just enough to try
|
||||
//! the canvas.
|
||||
//! 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
|
||||
//! views come in later stages.
|
||||
//!
|
||||
//! Run it with `cargo run --example canvas`.
|
||||
//! Run it with `cargo run --example canvas`. A few keys to start with:
|
||||
//!
|
||||
//! | Keys | What they do |
|
||||
//! |----------------|-----------------------------------------------------|
|
||||
//! | `hjkl`, arrows | move to the nearest node that way |
|
||||
//! | `gh gl gj gk` | parent, first child, next / previous sibling |
|
||||
//! | `gg` | the root |
|
||||
//! | `HJKL` | pan the view |
|
||||
//! | `+` `-` | zoom in / out |
|
||||
//! | `za` `zf` `zi` | fold or unfold, auto-fold, focus |
|
||||
//! | `zz` | center the view on the cursor |
|
||||
//! | `a` `o` | add a child / a sibling below ("new idea") |
|
||||
//! | `x` `dd` | delete the node (its children move up) / the branch |
|
||||
//! | `M` | move mode: hjkl moves the branch, Enter or Esc ends |
|
||||
//! | `u` `Ctrl-r` | undo / redo |
|
||||
//! | `q` | quit |
|
||||
//! | Keys | What they do |
|
||||
//! |----------------|------------------------------------------------------|
|
||||
//! | `hjkl`, arrows | move to the nearest node that way (`5j`: five times) |
|
||||
//! | `g` | go to: `gh gl gj gk` the tree, `gg` the root |
|
||||
//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` |
|
||||
//! | `+` `-` | zoom in / out |
|
||||
//! | `a` `o` `O` | add a child / a sibling below / above |
|
||||
//! | `x` `dd` `dib` | delete the node / the branch / the children |
|
||||
//! | `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) |
|
||||
//! | `q`, `ZZ` | quit |
|
||||
//!
|
||||
//! Mouse: click selects, dragging a node moves its branch, dragging empty
|
||||
//! space pans, the wheel scrolls.
|
||||
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use cumulus::input::bindings::Context;
|
||||
use cumulus::input::command::{Bound, Command, Motion, Operator, Target, help_for};
|
||||
use cumulus::input::engine::{KeyEngine, Outcome, Typed};
|
||||
use cumulus::input::key::Key;
|
||||
use cumulus::input::keymap::Keymap;
|
||||
use cumulus::input::mouse::{Cell, Gesture, MouseReader};
|
||||
use cumulus::model::{
|
||||
CanvasOffset, Change, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title, Visible,
|
||||
CanvasOffset, Change, Direction, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title,
|
||||
Visible,
|
||||
};
|
||||
use cumulus::storage::document_file;
|
||||
use cumulus::ui::glyphs::Direction;
|
||||
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::{Camera, Scene, SceneOptions, ScreenPos};
|
||||
use ratatui::crossterm::event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
|
||||
MouseButton, MouseEvent, MouseEventKind,
|
||||
};
|
||||
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{DefaultTerminal, Frame};
|
||||
|
||||
/// The playground's own keys.toml: `q` quits. (cumulus itself keeps `q`
|
||||
/// free for macros; this shows how keys.toml changes a key.)
|
||||
const KEYS_TOML: &str = r#"
|
||||
[normal]
|
||||
"q" = "close"
|
||||
"#;
|
||||
|
||||
/// How long a sequence waits before its which-key popup shows (the leader's
|
||||
/// shows right away).
|
||||
const WHICH_KEY_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
const NORMAL: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions];
|
||||
const MOVING: &[Context] = &[Context::Move];
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let doc = document_file::parse(include_str!("garden.cum")).map_err(io::Error::other)?;
|
||||
let mut terminal = ratatui::try_init()?;
|
||||
@@ -61,10 +79,20 @@ fn main() -> io::Result<()> {
|
||||
result
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
Normal,
|
||||
/// Moving the cursor's node: its whole branch, or just the node. The
|
||||
/// undo step for the whole move builds up as it goes.
|
||||
Move {
|
||||
whole: bool,
|
||||
change: Option<Change>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A mouse drag in progress.
|
||||
enum Drag {
|
||||
/// Moving a node's branch: where the drag started, how far the branch has
|
||||
/// moved so far, and the undo step for the whole drag.
|
||||
/// Moving a node's branch: how far it has moved so far, and the undo
|
||||
/// step for the whole drag.
|
||||
Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, change: Option<Change> },
|
||||
/// Panning the view: the cell the mouse was last on.
|
||||
Pan { last: ScreenPos },
|
||||
@@ -77,11 +105,11 @@ struct Playground {
|
||||
camera: Camera,
|
||||
options: SceneOptions,
|
||||
theme: Theme,
|
||||
/// The first key of a two-key command (`g`, `z`, `d`), waiting for the
|
||||
/// second.
|
||||
pending: Option<char>,
|
||||
/// In move mode: the undo step for the whole move so far.
|
||||
moving: Option<Option<Change>>,
|
||||
keymap: Keymap,
|
||||
keys: KeyEngine,
|
||||
which_key: bool,
|
||||
mode: Mode,
|
||||
mouse: MouseReader,
|
||||
drag: Option<Drag>,
|
||||
/// The last frame drawn, to match clicks against.
|
||||
scene: Scene,
|
||||
@@ -93,6 +121,7 @@ struct Playground {
|
||||
|
||||
impl Playground {
|
||||
fn new(doc: Document) -> Playground {
|
||||
let (keymap, problems) = Keymap::from_toml(KEYS_TOML);
|
||||
Playground {
|
||||
cursor: doc.root(),
|
||||
doc,
|
||||
@@ -100,32 +129,50 @@ impl Playground {
|
||||
camera: Camera::default(),
|
||||
options: SceneOptions::default(),
|
||||
theme: Theme::default(),
|
||||
pending: None,
|
||||
moving: None,
|
||||
keymap,
|
||||
keys: KeyEngine::default(),
|
||||
which_key: true,
|
||||
mode: Mode::Normal,
|
||||
mouse: MouseReader::default(),
|
||||
drag: None,
|
||||
scene: Scene::default(),
|
||||
canvas_area: Rect::default(),
|
||||
follow: true,
|
||||
message: String::new(),
|
||||
message: problems.join(" · "),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
// While a sequence is half typed, wake up now and then: its
|
||||
// which-key popup may be due.
|
||||
let wait =
|
||||
if self.keys.is_pending() { Duration::from_millis(50) } else { Duration::from_secs(3600) };
|
||||
if !event::poll(wait)? {
|
||||
continue;
|
||||
}
|
||||
match event::read()? {
|
||||
// Windows reports key releases too: only presses and repeats count.
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if !self.key(key) {
|
||||
Event::Key(event) if event.kind != KeyEventKind::Release => {
|
||||
if let Some(key) = Key::from_event(event)
|
||||
&& !self.key(key)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse) => self.mouse(mouse),
|
||||
Event::Mouse(event) => {
|
||||
if let Some(gesture) = self.mouse.read(event, Instant::now()) {
|
||||
self.gesture(gesture);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Drawing --------------------------------------------------------
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
let [canvas, status] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
|
||||
@@ -140,131 +187,208 @@ impl Playground {
|
||||
}
|
||||
}
|
||||
frame.render_widget(&self.scene, canvas);
|
||||
frame.render_widget(self.status_line(), status);
|
||||
if self.which_key_due()
|
||||
&& let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts()))
|
||||
{
|
||||
frame.render_widget(WhichKey { hints: &hints, theme: &self.theme }, canvas);
|
||||
}
|
||||
self.draw_status(frame, status);
|
||||
}
|
||||
|
||||
fn build_scene(&self, area: Rect) -> Scene {
|
||||
Scene::build(&self.doc, self.cursor, self.camera, area.as_size(), &self.theme, self.options)
|
||||
}
|
||||
|
||||
fn status_line(&self) -> Line<'static> {
|
||||
let mode = if self.moving.is_some() { " MOVE " } else { " CANVAS " };
|
||||
/// The bottom line: the mode, the zoom and the cursor's title, then a
|
||||
/// message or key hints, and the keys typed so far on the right.
|
||||
fn draw_status(&self, frame: &mut Frame, area: Rect) {
|
||||
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",
|
||||
),
|
||||
Mode::Move { whole: true, .. } => {
|
||||
(" MOVE ", "hjkl moves the branch (HJKL by 5) · M just this node · Enter done")
|
||||
}
|
||||
Mode::Move { whole: false, .. } => {
|
||||
(" MOVE ", "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done")
|
||||
}
|
||||
};
|
||||
let title = self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default();
|
||||
let focus = if self.options.focus { " · focus" } else { "" };
|
||||
let hints = if self.message.is_empty() {
|
||||
"hjkl move · gh/gl tree · +/- zoom · za/zf/zi fold · a/o add · x/dd delete · M move · u undo · q quit"
|
||||
.to_string()
|
||||
} else {
|
||||
self.message.clone()
|
||||
};
|
||||
let pending = self.pending.map(|key| format!(" {key}")).unwrap_or_default();
|
||||
Line::from(vec![
|
||||
let middle = if self.message.is_empty() { hints.dark_gray() } else { self.message.clone().yellow() };
|
||||
let line = Line::from(vec![
|
||||
mode.black().on_cyan().bold(),
|
||||
format!(" {}{focus} │ {title} │ ", self.camera.zoom.name()).into(),
|
||||
hints.dark_gray(),
|
||||
pending.cyan().bold(),
|
||||
])
|
||||
middle,
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), area);
|
||||
let typed = Line::from(format!("{} ", self.keys.shown()).cyan().bold());
|
||||
frame.render_widget(Paragraph::new(typed).right_aligned(), area);
|
||||
}
|
||||
|
||||
/// Handles a key. Returns false to quit.
|
||||
fn key(&mut self, key: KeyEvent) -> bool {
|
||||
fn which_key_due(&self) -> bool {
|
||||
let delay = if self.keys.first_key() == Some(self.keymap.leader()) {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
WHICH_KEY_DELAY
|
||||
};
|
||||
self.which_key && self.keys.pending_since().is_some_and(|since| since.elapsed() >= delay)
|
||||
}
|
||||
|
||||
// ----- Keys -----------------------------------------------------------
|
||||
|
||||
fn contexts(&self) -> &'static [Context] {
|
||||
match self.mode {
|
||||
Mode::Normal => NORMAL,
|
||||
Mode::Move { .. } => MOVING,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a key press. Returns false to quit.
|
||||
fn key(&mut self, key: Key) -> bool {
|
||||
self.message.clear();
|
||||
if self.moving.is_some() {
|
||||
self.move_key(key);
|
||||
return true;
|
||||
}
|
||||
if let Some(first) = self.pending.take() {
|
||||
if let KeyCode::Char(second) = key.code {
|
||||
self.sequence(first, second);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Char('q') => return false,
|
||||
KeyCode::Char('c') if ctrl => return false,
|
||||
KeyCode::Char('r') if ctrl => self.redo(),
|
||||
KeyCode::Char('h') | KeyCode::Left => self.go(Direction::Left),
|
||||
KeyCode::Char('j') | KeyCode::Down => self.go(Direction::Down),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.go(Direction::Up),
|
||||
KeyCode::Char('l') | KeyCode::Right => self.go(Direction::Right),
|
||||
KeyCode::Char('H') => self.camera.pan(-8, 0),
|
||||
KeyCode::Char('J') => self.camera.pan(0, 4),
|
||||
KeyCode::Char('K') => self.camera.pan(0, -4),
|
||||
KeyCode::Char('L') => self.camera.pan(8, 0),
|
||||
KeyCode::Char('+' | '=') => self.zoom(true),
|
||||
KeyCode::Char('-') => self.zoom(false),
|
||||
KeyCode::Char('a') => self.add_child(),
|
||||
KeyCode::Char('o') => self.add_below(),
|
||||
KeyCode::Char('x') => self.delete_node(),
|
||||
KeyCode::Char('u') => self.undo(),
|
||||
KeyCode::Char('M') => {
|
||||
self.moving = Some(None);
|
||||
self.message = "hjkl moves the branch (HJKL by 5) · Enter or Esc ends".into();
|
||||
}
|
||||
KeyCode::Char(first @ ('g' | 'z' | 'd')) => self.pending = Some(first),
|
||||
_ => {}
|
||||
let outcome = self.keys.press(key, &self.keymap.tables(self.contexts()));
|
||||
match outcome {
|
||||
Outcome::Run(typed) => return self.run_command(typed),
|
||||
Outcome::Unknown(keys) => self.message = format!("{keys} does nothing here"),
|
||||
Outcome::Waiting | Outcome::Cancelled => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The second key of `g…`, `z…` and `d…`.
|
||||
fn sequence(&mut self, first: char, second: char) {
|
||||
match (first, second) {
|
||||
('g', 'g') => self.jump(Some(self.doc.root())),
|
||||
('g', 'h') => self.jump(self.doc.parent(self.cursor)),
|
||||
('g', 'l') => self.jump(self.doc.children(self.cursor).first().copied()),
|
||||
('g', 'j') => self.jump(self.doc.next_sibling(self.cursor)),
|
||||
('g', 'k') => self.jump(self.doc.previous_sibling(self.cursor)),
|
||||
('z', 'a') => self.toggle_fold(),
|
||||
('z', 'f') => self.set_fold(Fold::Auto),
|
||||
('z', 'i') => {
|
||||
/// Runs a command. Returns false to quit.
|
||||
fn run_command(&mut self, typed: Typed) -> bool {
|
||||
if let Mode::Move { .. } = self.mode {
|
||||
self.move_command(typed);
|
||||
return true;
|
||||
}
|
||||
let times = typed.times();
|
||||
let steps = i32::try_from(times).unwrap_or(1);
|
||||
match typed.command {
|
||||
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::Children) => self.delete_children(),
|
||||
Command::DeleteNode => self.delete_node(),
|
||||
Command::AddChild => self.add(Spot::LastChildOf(self.cursor)),
|
||||
Command::AddBelow => self.add(Spot::After(self.cursor)),
|
||||
Command::AddAbove => self.add(Spot::Before(self.cursor)),
|
||||
Command::Undo => (0..times).for_each(|_| self.undo()),
|
||||
Command::Redo => (0..times).for_each(|_| self.redo()),
|
||||
Command::ZoomIn => self.zoom(true),
|
||||
Command::ZoomOut => self.zoom(false),
|
||||
Command::ToggleFold => self.toggle_fold(),
|
||||
Command::OpenFold => self.set_fold(Fold::Open),
|
||||
Command::CloseFold => self.set_fold(Fold::Closed),
|
||||
Command::AutoFold => self.set_fold(Fold::Auto),
|
||||
Command::OpenAllFolds => self.fold_all(Fold::Open),
|
||||
Command::CloseAllFolds => self.fold_all(Fold::Closed),
|
||||
Command::ToggleFocus => {
|
||||
self.options.focus = !self.options.focus;
|
||||
self.message = format!("focus {}", if self.options.focus { "on" } else { "off" });
|
||||
}
|
||||
('z', 'z') => self.center(),
|
||||
('d', 'd') => self.delete_branch(),
|
||||
_ => self.message = format!("{first}{second} does nothing here"),
|
||||
Command::Center => self.center(),
|
||||
Command::ScrollDown => self.camera.pan(0, 3 * steps),
|
||||
Command::ScrollUp => self.camera.pan(0, -3 * steps),
|
||||
Command::ScrollLeft => self.camera.pan(-6 * steps, 0),
|
||||
Command::ScrollRight => self.camera.pan(6 * steps, 0),
|
||||
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::ToggleWhichKey => {
|
||||
self.which_key = !self.which_key;
|
||||
self.message = format!("key hints {}", if self.which_key { "on" } else { "off" });
|
||||
}
|
||||
Command::Escape => {}
|
||||
Command::Close => return false,
|
||||
other => {
|
||||
let help = help_for(Bound::Command(other));
|
||||
self.message = format!("\"{help}\" comes in a later stage of the build");
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn move_key(&mut self, key: KeyEvent) {
|
||||
let (cols, rows) = match key.code {
|
||||
KeyCode::Char('h') | KeyCode::Left => (-1, 0),
|
||||
KeyCode::Char('l') | KeyCode::Right => (1, 0),
|
||||
KeyCode::Char('j') | KeyCode::Down => (0, 1),
|
||||
KeyCode::Char('k') | KeyCode::Up => (0, -1),
|
||||
KeyCode::Char('H') => (-5, 0),
|
||||
KeyCode::Char('L') => (5, 0),
|
||||
KeyCode::Char('J') => (0, 5),
|
||||
KeyCode::Char('K') => (0, -5),
|
||||
KeyCode::Enter | KeyCode::Esc | KeyCode::Char('M') => {
|
||||
if let Some(Some(change)) = self.moving.take() {
|
||||
fn motion(&mut self, motion: Motion) {
|
||||
let doc = &self.doc;
|
||||
let target = match motion {
|
||||
Motion::Left => return self.go(Direction::Left),
|
||||
Motion::Down => return self.go(Direction::Down),
|
||||
Motion::Up => return self.go(Direction::Up),
|
||||
Motion::Right => return self.go(Direction::Right),
|
||||
Motion::Parent => doc.parent(self.cursor),
|
||||
Motion::FirstChild => doc.children(self.cursor).first().copied(),
|
||||
Motion::NextSibling => doc.next_sibling(self.cursor),
|
||||
Motion::PreviousSibling => doc.previous_sibling(self.cursor),
|
||||
Motion::First => Some(doc.root()),
|
||||
Motion::Next | Motion::Previous | Motion::Last => {
|
||||
let order = Visible::new(doc, self.cursor, self.options.focus).order;
|
||||
let here = order.iter().position(|&id| id == self.cursor).unwrap_or(0);
|
||||
match motion {
|
||||
Motion::Next => order.get(here + 1).copied(),
|
||||
Motion::Previous => here.checked_sub(1).and_then(|index| order.get(index).copied()),
|
||||
_ => order.last().copied(),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.message = "search comes in a later stage of the build".into();
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.jump(target);
|
||||
}
|
||||
|
||||
/// Commands in move mode: hjkl moves the node or its branch.
|
||||
fn move_command(&mut self, typed: Typed) {
|
||||
let step = i32::try_from(typed.times()).unwrap_or(1);
|
||||
let (cols, rows) = match typed.command {
|
||||
Command::Move(Motion::Left) => (-step, 0),
|
||||
Command::Move(Motion::Right) => (step, 0),
|
||||
Command::Move(Motion::Down) => (0, step),
|
||||
Command::Move(Motion::Up) => (0, -step),
|
||||
Command::FarLeft => (-5 * step, 0),
|
||||
Command::FarRight => (5 * step, 0),
|
||||
Command::FarDown => (0, 5 * step),
|
||||
Command::FarUp => (0, -5 * step),
|
||||
Command::ToggleWhole => {
|
||||
if let Mode::Move { whole, .. } = &mut self.mode {
|
||||
*whole = !*whole;
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
if let Mode::Move { change: Some(change), .. } =
|
||||
std::mem::replace(&mut self.mode, Mode::Normal)
|
||||
{
|
||||
self.history.record(change);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let (id, offset) = (self.cursor, self.camera.cells(cols, rows));
|
||||
if let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, offset)) {
|
||||
let so_far = self.moving.take().flatten();
|
||||
self.moving = Some(Some(match so_far {
|
||||
Some(change) => change.then(next),
|
||||
let whole = matches!(self.mode, Mode::Move { whole: true, .. });
|
||||
let result = self.doc.edit(|edit| {
|
||||
if whole { edit.shift_branch(id, offset) } else { edit.update(id, |node| node.pos += offset) }
|
||||
});
|
||||
if let (Ok(((), next)), Mode::Move { change, .. }) = (result, &mut self.mode) {
|
||||
*change = Some(match change.take() {
|
||||
Some(so_far) => so_far.then(next),
|
||||
None => next,
|
||||
}));
|
||||
});
|
||||
self.follow = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(&mut self, mouse: MouseEvent) {
|
||||
// ----- The mouse ------------------------------------------------------
|
||||
|
||||
fn gesture(&mut self, gesture: Gesture) {
|
||||
let area = self.canvas_area;
|
||||
let at = ScreenPos::new(
|
||||
i32::from(mouse.column) - i32::from(area.x),
|
||||
i32::from(mouse.row) - i32::from(area.y),
|
||||
);
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
let screen = |cell: Cell| {
|
||||
ScreenPos::new(i32::from(cell.col) - i32::from(area.x), i32::from(cell.row) - i32::from(area.y))
|
||||
};
|
||||
match gesture {
|
||||
Gesture::Press { at, .. } => {
|
||||
let at = screen(at);
|
||||
self.drag = Some(match self.scene.node_at(at) {
|
||||
Some(id) => {
|
||||
self.cursor = id;
|
||||
@@ -273,40 +397,49 @@ impl Playground {
|
||||
None => Drag::Pan { last: at },
|
||||
});
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => match &mut self.drag {
|
||||
Some(Drag::Node { id, from, moved, change }) => {
|
||||
let total = self.camera.cells(at.col - from.col, at.row - from.row);
|
||||
let step = CanvasOffset::new(total.dx - moved.dx, total.dy - moved.dy);
|
||||
let id = *id;
|
||||
if step != CanvasOffset::default()
|
||||
&& let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, step))
|
||||
{
|
||||
*moved = total;
|
||||
*change = Some(match change.take() {
|
||||
Some(so_far) => so_far.then(next),
|
||||
None => next,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Drag::Pan { last }) => {
|
||||
self.camera.pan(last.col - at.col, last.row - at.row);
|
||||
*last = at;
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
Gesture::DoublePress { .. } => self.message = "editing titles comes in stage 6".into(),
|
||||
Gesture::Drag { at, .. } => self.drag_to(screen(at)),
|
||||
Gesture::Release { .. } => {
|
||||
if let Some(Drag::Node { change: Some(change), .. }) = self.drag.take() {
|
||||
self.history.record(change);
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollDown => self.camera.pan(0, 3),
|
||||
MouseEventKind::ScrollUp => self.camera.pan(0, -3),
|
||||
MouseEventKind::ScrollRight => self.camera.pan(6, 0),
|
||||
MouseEventKind::ScrollLeft => self.camera.pan(-6, 0),
|
||||
_ => {}
|
||||
Gesture::Scroll { direction, .. } => match direction {
|
||||
Direction::Up => self.camera.pan(0, -3),
|
||||
Direction::Down => self.camera.pan(0, 3),
|
||||
Direction::Left => self.camera.pan(-6, 0),
|
||||
Direction::Right => self.camera.pan(6, 0),
|
||||
},
|
||||
Gesture::RightPress { .. } => self.message = "the right-click menu comes in stage 7".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn drag_to(&mut self, at: ScreenPos) {
|
||||
match &mut self.drag {
|
||||
Some(Drag::Node { id, from, moved, change }) => {
|
||||
let total = self.camera.cells(at.col - from.col, at.row - from.row);
|
||||
let step = CanvasOffset::new(total.dx - moved.dx, total.dy - moved.dy);
|
||||
let id = *id;
|
||||
if step != CanvasOffset::default()
|
||||
&& let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, step))
|
||||
{
|
||||
*moved = total;
|
||||
*change = Some(match change.take() {
|
||||
Some(so_far) => so_far.then(next),
|
||||
None => next,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Drag::Pan { last }) => {
|
||||
self.camera.pan(last.col - at.col, last.row - at.row);
|
||||
*last = at;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- What the commands do -------------------------------------------
|
||||
|
||||
/// Moves the cursor to the nearest node on show in a direction.
|
||||
fn go(&mut self, direction: Direction) {
|
||||
let visible = Visible::new(&self.doc, self.cursor, self.options.focus);
|
||||
@@ -358,30 +491,27 @@ impl Playground {
|
||||
self.message = format!("fold: {fold:?}");
|
||||
}
|
||||
|
||||
fn add_child(&mut self) {
|
||||
let Some(node) = self.new_idea() else { return };
|
||||
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
|
||||
let size = NodeBox::new(&node, String::new(), self.options.max_width, self.theme.glyphs).rect.size;
|
||||
let parent = self.cursor;
|
||||
let node = Node { pos: child_spot(&self.doc, parent, size, &rects), ..node };
|
||||
let added = self.edit(|edit| edit.add(node, Spot::LastChildOf(parent)));
|
||||
self.jump(added);
|
||||
fn fold_all(&mut self, fold: Fold) {
|
||||
let parents: Vec<NodeId> =
|
||||
self.doc.outline().into_iter().filter(|&id| !self.doc.children(id).is_empty()).collect();
|
||||
self.edit(|edit| parents.iter().try_for_each(|&id| edit.update(id, |node| node.fold = fold)));
|
||||
}
|
||||
|
||||
fn add_below(&mut self) {
|
||||
let Some(node) = self.new_idea() else { return };
|
||||
/// Adds a "new idea" node at `spot`, placed next to its parent or
|
||||
/// sibling, and moves the cursor to it.
|
||||
fn add(&mut self, spot: Spot) {
|
||||
let Ok(title) = Title::parse("new idea") else { return };
|
||||
let node = Node::idea(title, Default::default());
|
||||
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
|
||||
let size = NodeBox::new(&node, String::new(), self.options.max_width, self.theme.glyphs).rect.size;
|
||||
let sibling = self.cursor;
|
||||
let node = Node { pos: below_spot(sibling, size, &rects), ..node };
|
||||
let added = self.edit(|edit| edit.add(node, Spot::After(sibling)));
|
||||
let pos = match spot {
|
||||
Spot::LastChildOf(parent) => child_spot(&self.doc, parent, size, &rects),
|
||||
_ => below_spot(self.cursor, size, &rects),
|
||||
};
|
||||
let added = self.edit(|edit| edit.add(Node { pos, ..node }, spot));
|
||||
self.jump(added);
|
||||
}
|
||||
|
||||
fn new_idea(&self) -> Option<Node> {
|
||||
Title::parse("new idea").ok().map(|title| Node::idea(title, Default::default()))
|
||||
}
|
||||
|
||||
fn delete_node(&mut self) {
|
||||
let id = self.cursor;
|
||||
let next = self.doc.children(id).first().copied().or_else(|| self.doc.parent(id));
|
||||
@@ -399,6 +529,20 @@ impl Playground {
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_children(&mut self) {
|
||||
let children = self.doc.children(self.cursor).to_vec();
|
||||
let removed = self.edit(|edit| {
|
||||
let mut count = 0;
|
||||
for &child in &children {
|
||||
count += edit.remove_branch(child)?.len();
|
||||
}
|
||||
Ok(count)
|
||||
});
|
||||
if let Some(count) = removed {
|
||||
self.message = format!("deleted {count} node(s) · u to undo");
|
||||
}
|
||||
}
|
||||
|
||||
fn undo(&mut self) {
|
||||
let touched: Vec<NodeId> = match self.history.undo(&mut self.doc) {
|
||||
Some(change) => change.nodes().collect(),
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! The default keys, one table per place they work. To change a key for
|
||||
//! everyone, change it here; to change it just for you, use `keys.toml`.
|
||||
//! Which-key and the help read these same tables, so they can't drift apart.
|
||||
//!
|
||||
//! Keys are written in vim notation (see [`key`](super::key)); `<leader>` is
|
||||
//! the leader key, `<Space>` unless `keys.toml` says otherwise.
|
||||
|
||||
use super::command::{Bound, CharKey, Command as C, Motion as M, Operator as Op, Target, TargetBound};
|
||||
|
||||
/// Where a table's keys work. Each is a section in `keys.toml`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Context {
|
||||
/// Keys that move the cursor, in normal, visual and connect modes.
|
||||
Motions,
|
||||
/// Normal mode, in every view.
|
||||
Normal,
|
||||
/// Normal mode on the canvas, on top of `Normal`.
|
||||
Canvas,
|
||||
/// Normal mode in the outline, on top of `Normal`.
|
||||
Outline,
|
||||
/// Normal mode in the Note view, on top of `Normal`.
|
||||
Note,
|
||||
Visual,
|
||||
Move,
|
||||
Connect,
|
||||
/// Lists in popups: pickers, menus, the help.
|
||||
List,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub const ALL: [Context; 9] = [
|
||||
Context::Motions,
|
||||
Context::Normal,
|
||||
Context::Canvas,
|
||||
Context::Outline,
|
||||
Context::Note,
|
||||
Context::Visual,
|
||||
Context::Move,
|
||||
Context::Connect,
|
||||
Context::List,
|
||||
];
|
||||
|
||||
/// The section's name in `keys.toml`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Context::Motions => "motions",
|
||||
Context::Normal => "normal",
|
||||
Context::Canvas => "canvas",
|
||||
Context::Outline => "outline",
|
||||
Context::Note => "note",
|
||||
Context::Visual => "visual",
|
||||
Context::Move => "move",
|
||||
Context::Connect => "connect",
|
||||
Context::List => "list",
|
||||
}
|
||||
}
|
||||
|
||||
/// The context with this section name in `keys.toml`.
|
||||
pub fn named(name: &str) -> Option<Context> {
|
||||
Context::ALL.into_iter().find(|context| context.name() == name)
|
||||
}
|
||||
|
||||
/// The default keys of this context.
|
||||
pub fn defaults(self) -> &'static [DefaultBinding] {
|
||||
match self {
|
||||
Context::Motions => MOTIONS,
|
||||
Context::Normal => NORMAL,
|
||||
Context::Canvas => CANVAS,
|
||||
Context::Outline | Context::Note => &[],
|
||||
Context::Visual => VISUAL,
|
||||
Context::Move => MOVE,
|
||||
Context::Connect => CONNECT,
|
||||
Context::List => LIST,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tables that work together in each mode and view, most specific first:
|
||||
/// when two tables bind the same keys, the first one wins.
|
||||
pub const SITUATIONS: &[(&str, &[Context])] = &[
|
||||
("normal mode on the canvas", &[Context::Canvas, Context::Normal, Context::Motions]),
|
||||
("normal mode in the outline", &[Context::Outline, Context::Normal, Context::Motions]),
|
||||
("normal mode in the Note view", &[Context::Note, Context::Normal, Context::Motions]),
|
||||
("visual mode", &[Context::Visual, Context::Canvas, Context::Motions]),
|
||||
("move mode", &[Context::Move]),
|
||||
("connect mode", &[Context::Connect, Context::Canvas, Context::Motions]),
|
||||
("lists", &[Context::List]),
|
||||
];
|
||||
|
||||
/// A default binding: keys in notation, what they do, and how to say it
|
||||
/// here when the command's own words don't fit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DefaultBinding {
|
||||
pub keys: &'static str,
|
||||
pub bound: Bound,
|
||||
pub help: Option<&'static str>,
|
||||
}
|
||||
|
||||
const fn bind(keys: &'static str, command: C) -> DefaultBinding {
|
||||
DefaultBinding { keys, bound: Bound::Command(command), help: None }
|
||||
}
|
||||
|
||||
const fn bind_help(keys: &'static str, command: C, help: &'static str) -> DefaultBinding {
|
||||
DefaultBinding { keys, bound: Bound::Command(command), help: Some(help) }
|
||||
}
|
||||
|
||||
const fn operator(keys: &'static str, operator: Op) -> DefaultBinding {
|
||||
DefaultBinding { keys, bound: Bound::Operator(operator), help: None }
|
||||
}
|
||||
|
||||
const fn with_char(keys: &'static str, key: CharKey) -> DefaultBinding {
|
||||
DefaultBinding { keys, bound: Bound::WithChar(key), help: None }
|
||||
}
|
||||
|
||||
const fn go(keys: &'static str, motion: M) -> DefaultBinding {
|
||||
bind(keys, C::Move(motion))
|
||||
}
|
||||
|
||||
/// Moving the cursor: in normal, visual and connect modes.
|
||||
const MOTIONS: &[DefaultBinding] = &[
|
||||
go("h", M::Left),
|
||||
go("<Left>", M::Left),
|
||||
go("j", M::Down),
|
||||
go("<Down>", M::Down),
|
||||
go("k", M::Up),
|
||||
go("<Up>", M::Up),
|
||||
go("l", M::Right),
|
||||
go("<Right>", M::Right),
|
||||
go("gh", M::Parent),
|
||||
go("gl", M::FirstChild),
|
||||
go("gj", M::NextSibling),
|
||||
go("gk", M::PreviousSibling),
|
||||
go("w", M::Next),
|
||||
go("b", M::Previous),
|
||||
go("gg", M::First),
|
||||
go("G", M::Last),
|
||||
bind("/", C::Search),
|
||||
go("n", M::SearchNext),
|
||||
go("N", M::SearchPrevious),
|
||||
bind("<C-o>", C::JumpBack),
|
||||
// Most terminals send Tab for Ctrl-i, so both jump forward, as in vim.
|
||||
bind("<Tab>", C::JumpForward),
|
||||
bind("<C-i>", C::JumpForward),
|
||||
with_char("m", CharKey::SetMark),
|
||||
with_char("'", CharKey::JumpToMark),
|
||||
with_char("`", CharKey::JumpToMark),
|
||||
bind("gf", C::FollowPortal),
|
||||
bind("gd", C::FollowLink),
|
||||
bind("zz", C::Center),
|
||||
bind("<C-e>", C::ScrollDown),
|
||||
bind("<C-y>", C::ScrollUp),
|
||||
bind("zh", C::ScrollLeft),
|
||||
bind("zl", C::ScrollRight),
|
||||
bind("<C-d>", C::HalfPageDown),
|
||||
bind("<C-u>", C::HalfPageUp),
|
||||
];
|
||||
|
||||
/// Normal mode, in every view.
|
||||
const NORMAL: &[DefaultBinding] = &[
|
||||
bind_help("<CR>", C::Activate, "edit the title (a portal: go through it)"),
|
||||
bind("i", C::EditTitle),
|
||||
bind("A", C::EditTitle),
|
||||
bind("I", C::EditTitleAtStart),
|
||||
bind("cc", C::RetypeTitle),
|
||||
bind("S", C::RetypeTitle),
|
||||
bind("o", C::AddBelow),
|
||||
bind("O", C::AddAbove),
|
||||
bind("a", C::AddChild),
|
||||
bind("x", C::DeleteNode),
|
||||
operator("d", Op::Delete),
|
||||
operator("y", Op::Yank),
|
||||
operator(">", Op::Indent),
|
||||
operator("<lt>", Op::Outdent),
|
||||
bind("p", C::PasteAfter),
|
||||
bind("P", C::PasteBefore),
|
||||
bind("]p", C::PasteAsChild),
|
||||
bind("u", C::Undo),
|
||||
bind("<C-r>", C::Redo),
|
||||
bind(".", C::Repeat),
|
||||
bind("K", C::ShowBody),
|
||||
bind("za", C::ToggleFold),
|
||||
bind("zo", C::OpenFold),
|
||||
bind("zc", C::CloseFold),
|
||||
bind("zf", C::AutoFold),
|
||||
bind("zR", C::OpenAllFolds),
|
||||
bind("zM", C::CloseAllFolds),
|
||||
bind("zi", C::ToggleFocus),
|
||||
bind("v", C::Visual),
|
||||
bind("V", C::VisualBranches),
|
||||
bind("M", C::MoveMode),
|
||||
bind("C", C::ConnectMode),
|
||||
bind(":", C::CommandLine),
|
||||
bind("<Esc>", C::Escape),
|
||||
bind("<C-c>", C::Escape),
|
||||
bind("<leader>c", C::PickColor),
|
||||
bind("<leader>s", C::PickStyle),
|
||||
bind("<leader>B", C::PickBorder),
|
||||
bind("<leader>t", C::EditTags),
|
||||
bind("<leader>p", C::NewPortal),
|
||||
bind("<leader>i", C::Capture),
|
||||
bind("<leader>b", C::PickDocument),
|
||||
bind("<leader>vc", C::ShowCanvas),
|
||||
bind("<leader>vo", C::ShowOutline),
|
||||
bind("<leader>vn", C::ShowNote),
|
||||
bind("<leader>w", C::ToggleWhichKey),
|
||||
bind("<leader>?", C::Help),
|
||||
bind("?", C::Help),
|
||||
bind("<C-w>v", C::SplitVertical),
|
||||
bind("<C-w>s", C::SplitHorizontal),
|
||||
bind("<C-w>h", C::FocusLeft),
|
||||
bind("<C-w>j", C::FocusDown),
|
||||
bind("<C-w>k", C::FocusUp),
|
||||
bind("<C-w>l", C::FocusRight),
|
||||
bind("<C-w>w", C::NextPane),
|
||||
bind("<C-w>c", C::ClosePane),
|
||||
bind("<C-w>q", C::ClosePane),
|
||||
bind("<C-w>o", C::OnlyPane),
|
||||
bind("<C-w>=", C::EqualizePanes),
|
||||
bind("gt", C::NextDocument),
|
||||
bind("gT", C::PreviousDocument),
|
||||
bind("]b", C::NextDocument),
|
||||
bind("[b", C::PreviousDocument),
|
||||
bind("ZZ", C::Close),
|
||||
];
|
||||
|
||||
/// Normal mode on the canvas.
|
||||
const CANVAS: &[DefaultBinding] = &[bind("+", C::ZoomIn), bind("-", C::ZoomOut)];
|
||||
|
||||
/// Visual mode: operators act on the selection right away.
|
||||
const VISUAL: &[DefaultBinding] = &[
|
||||
bind("o", C::SwapEnds),
|
||||
bind("d", C::Operate(Op::Delete, Target::Selection)),
|
||||
bind("x", C::Operate(Op::Delete, Target::Selection)),
|
||||
bind("y", C::Operate(Op::Yank, Target::Selection)),
|
||||
bind(">", C::Operate(Op::Indent, Target::Selection)),
|
||||
bind("<lt>", C::Operate(Op::Outdent, Target::Selection)),
|
||||
bind("M", C::MoveMode),
|
||||
bind("C", C::ConnectMode),
|
||||
bind("<leader>c", C::PickColor),
|
||||
bind("<leader>s", C::PickStyle),
|
||||
bind("<leader>B", C::PickBorder),
|
||||
bind("<leader>t", C::EditTags),
|
||||
bind_help("v", C::Visual, "select a rectangle (again: stop)"),
|
||||
bind_help("V", C::VisualBranches, "select whole branches (again: stop)"),
|
||||
bind_help("<Esc>", C::Escape, "stop selecting"),
|
||||
bind_help("<C-c>", C::Escape, "stop selecting"),
|
||||
];
|
||||
|
||||
/// Move mode: the cursor's node (or branch) moves instead of the cursor.
|
||||
const MOVE: &[DefaultBinding] = &[
|
||||
bind_help("h", C::Move(M::Left), "move it left"),
|
||||
bind_help("<Left>", C::Move(M::Left), "move it left"),
|
||||
bind_help("j", C::Move(M::Down), "move it down"),
|
||||
bind_help("<Down>", C::Move(M::Down), "move it down"),
|
||||
bind_help("k", C::Move(M::Up), "move it up"),
|
||||
bind_help("<Up>", C::Move(M::Up), "move it up"),
|
||||
bind_help("l", C::Move(M::Right), "move it right"),
|
||||
bind_help("<Right>", C::Move(M::Right), "move it right"),
|
||||
bind("H", C::FarLeft),
|
||||
bind("J", C::FarDown),
|
||||
bind("K", C::FarUp),
|
||||
bind("L", C::FarRight),
|
||||
bind("M", C::ToggleWhole),
|
||||
bind_help("<CR>", C::Confirm, "done"),
|
||||
bind_help("<Esc>", C::Escape, "done"),
|
||||
bind_help("<C-c>", C::Escape, "done"),
|
||||
];
|
||||
|
||||
/// Connect mode: walk to another node, then link to it.
|
||||
const CONNECT: &[DefaultBinding] = &[
|
||||
bind_help("<CR>", C::Confirm, "connect to this node (or save the link)"),
|
||||
bind("d", C::RemoveLink),
|
||||
bind(">", C::AttachAsChild),
|
||||
bind_help("<leader>s", C::PickStyle, "the link's style…"),
|
||||
bind("<leader>a", C::CycleArrows),
|
||||
bind("<leader>l", C::CycleLine),
|
||||
bind_help("<leader>c", C::PickColor, "the link's color…"),
|
||||
bind("<leader>t", C::EditLabel),
|
||||
bind_help("<Esc>", C::Escape, "cancel"),
|
||||
bind_help("<C-c>", C::Escape, "cancel"),
|
||||
];
|
||||
|
||||
/// Lists in popups.
|
||||
const LIST: &[DefaultBinding] = &[
|
||||
go("j", M::Down),
|
||||
go("<Down>", M::Down),
|
||||
go("k", M::Up),
|
||||
go("<Up>", M::Up),
|
||||
go("gg", M::First),
|
||||
go("G", M::Last),
|
||||
bind("<C-d>", C::HalfPageDown),
|
||||
bind("<C-u>", C::HalfPageUp),
|
||||
bind("/", C::Search),
|
||||
bind_help("<CR>", C::Confirm, "pick"),
|
||||
bind_help("<Esc>", C::Escape, "close"),
|
||||
bind_help("q", C::Escape, "close"),
|
||||
];
|
||||
|
||||
/// What an operator can act on, besides its own key again (`dd`: this node
|
||||
/// and its branch).
|
||||
pub const NODE_TARGETS: &[(&str, TargetBound, &str)] = &[
|
||||
("ab", TargetBound::Target(Target::Branch), "the branch: this node and all below"),
|
||||
("ib", TargetBound::Target(Target::Children), "inside the branch: just what's below"),
|
||||
];
|
||||
|
||||
/// Names for key groups: the title of which-key's popup, and its `+group`
|
||||
/// entries.
|
||||
pub const GROUPS: &[(&str, &str)] = &[
|
||||
("g", "go to"),
|
||||
("z", "view & folds"),
|
||||
("<leader>", "actions"),
|
||||
("<leader>v", "show a view"),
|
||||
("<C-w>", "panes"),
|
||||
("[", "previous"),
|
||||
("]", "next"),
|
||||
("Z", "close"),
|
||||
("c", "change"),
|
||||
];
|
||||
|
||||
/// Names for groups of targets, after an operator.
|
||||
pub const TARGET_GROUPS: &[(&str, &str)] = &[("a", "around"), ("i", "inside")];
|
||||
@@ -0,0 +1,474 @@
|
||||
//! Everything a key can ask for. Views never see raw key presses: the engine
|
||||
//! turns them into [`Command`]s using the tables in
|
||||
//! [`bindings`](super::bindings), and each view decides what a command means
|
||||
//! there. `Move(Left)` goes to the nearest node on the left on the canvas,
|
||||
//! for example, and to the parent in the outline.
|
||||
//!
|
||||
//! [`COMMANDS`] names every command, for `keys.toml`, and says what it does,
|
||||
//! for which-key and the help.
|
||||
|
||||
/// Ways to move the cursor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Motion {
|
||||
Left,
|
||||
Down,
|
||||
Up,
|
||||
Right,
|
||||
Parent,
|
||||
FirstChild,
|
||||
NextSibling,
|
||||
PreviousSibling,
|
||||
/// The next node in reading order (in text: the next word).
|
||||
Next,
|
||||
/// The previous node in reading order (in text: the previous word).
|
||||
Previous,
|
||||
/// The root (in text: the start).
|
||||
First,
|
||||
/// The last node (in text: the end).
|
||||
Last,
|
||||
SearchNext,
|
||||
SearchPrevious,
|
||||
/// To the next time a character shows up on the line (`f`, in text).
|
||||
FindForward(char),
|
||||
/// To the previous time a character shows up on the line (`F`).
|
||||
FindBackward(char),
|
||||
/// To just before the next time a character shows up (`t`).
|
||||
TillForward(char),
|
||||
/// To just after the previous time a character shows up (`T`).
|
||||
TillBackward(char),
|
||||
}
|
||||
|
||||
/// Keys that wait for a target, like vim's `d`: `dd`, `dab`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Operator {
|
||||
Delete,
|
||||
Yank,
|
||||
Change,
|
||||
Indent,
|
||||
Outdent,
|
||||
}
|
||||
|
||||
impl Operator {
|
||||
/// What the operator does, for which-key's title.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Operator::Delete => "delete",
|
||||
Operator::Yank => "yank",
|
||||
Operator::Change => "change",
|
||||
Operator::Indent => "indent",
|
||||
Operator::Outdent => "outdent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an operator acts on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Target {
|
||||
/// The operator's own key again (`dd`, `yy`, `>>`): this node, with its
|
||||
/// branch (in text: this line).
|
||||
Current,
|
||||
/// `ab`, around a branch: the node and everything below it.
|
||||
Branch,
|
||||
/// `ib`, inside a branch: just what's below the node.
|
||||
Children,
|
||||
/// What's selected (an operator in visual mode).
|
||||
Selection,
|
||||
/// From the cursor to where a motion goes (`dw`, `dfx`, in text).
|
||||
Motion(Motion),
|
||||
}
|
||||
|
||||
/// Keys that need one more character, typed right after them: `m` then a
|
||||
/// letter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CharKey {
|
||||
SetMark,
|
||||
JumpToMark,
|
||||
FindForward,
|
||||
FindBackward,
|
||||
TillForward,
|
||||
TillBackward,
|
||||
}
|
||||
|
||||
impl CharKey {
|
||||
/// The command once the character is known.
|
||||
pub fn command(self, c: char) -> Command {
|
||||
match self {
|
||||
CharKey::SetMark => Command::SetMark(c),
|
||||
CharKey::JumpToMark => Command::JumpToMark(c),
|
||||
CharKey::FindForward => Command::Move(Motion::FindForward(c)),
|
||||
CharKey::FindBackward => Command::Move(Motion::FindBackward(c)),
|
||||
CharKey::TillForward => Command::Move(Motion::TillForward(c)),
|
||||
CharKey::TillBackward => Command::Move(Motion::TillBackward(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// As the target of an operator, once the character is known (`dfx`).
|
||||
/// Only motions can be targets.
|
||||
pub fn target(self, c: char) -> Option<Target> {
|
||||
match self.command(c) {
|
||||
Command::Move(motion) => Some(Target::Motion(motion)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the character is for, for which-key: the keys, and what they do.
|
||||
pub fn hints(self) -> &'static [(&'static str, &'static str)] {
|
||||
match self {
|
||||
CharKey::SetMark => &[("a–z", "mark this node"), ("A–Z", "mark it, across documents")],
|
||||
CharKey::JumpToMark => &[("a–z A–Z", "go to that mark")],
|
||||
_ => &[("any key", "the character to go to")],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where text goes and comes from with yank and paste: `"a` before a
|
||||
/// command picks register `a`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Register {
|
||||
/// The usual one, when no register is named.
|
||||
Unnamed,
|
||||
/// `"a` to `"z` (capitals name the same registers).
|
||||
Named(char),
|
||||
/// `"+` (or `"*`): the system clipboard.
|
||||
Clipboard,
|
||||
/// `"_`: nowhere, for deleting without keeping a copy.
|
||||
BlackHole,
|
||||
}
|
||||
|
||||
impl Register {
|
||||
/// The register a character names after `"`, if it names one.
|
||||
pub fn named(c: char) -> Option<Register> {
|
||||
match c {
|
||||
'"' => Some(Register::Unnamed),
|
||||
'a'..='z' => Some(Register::Named(c)),
|
||||
'A'..='Z' => Some(Register::Named(c.to_ascii_lowercase())),
|
||||
'+' | '*' => Some(Register::Clipboard),
|
||||
'_' => Some(Register::BlackHole),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The registers, for which-key after `"`.
|
||||
pub const HINTS: &[(&str, &str)] = &[
|
||||
("a–z", "a named register"),
|
||||
("+", "the system clipboard"),
|
||||
("_", "nowhere (delete without a copy)"),
|
||||
];
|
||||
}
|
||||
|
||||
/// Everything a key sequence can ask for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Command {
|
||||
/// Move the cursor.
|
||||
Move(Motion),
|
||||
/// An operator with its target: `dd`, `yab`.
|
||||
Operate(Operator, Target),
|
||||
/// Mark the cursor's node with a letter (`ma`).
|
||||
SetMark(char),
|
||||
/// Go to a marked node (`'a`).
|
||||
JumpToMark(char),
|
||||
|
||||
// Jumping around.
|
||||
Search,
|
||||
JumpBack,
|
||||
JumpForward,
|
||||
FollowPortal,
|
||||
FollowLink,
|
||||
/// Enter: edit the title, or go through a portal.
|
||||
Activate,
|
||||
|
||||
// The view.
|
||||
Center,
|
||||
ScrollDown,
|
||||
ScrollUp,
|
||||
ScrollLeft,
|
||||
ScrollRight,
|
||||
HalfPageDown,
|
||||
HalfPageUp,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
ToggleFold,
|
||||
OpenFold,
|
||||
CloseFold,
|
||||
AutoFold,
|
||||
OpenAllFolds,
|
||||
CloseAllFolds,
|
||||
ToggleFocus,
|
||||
ShowBody,
|
||||
|
||||
// Creating and editing.
|
||||
AddBelow,
|
||||
AddAbove,
|
||||
AddChild,
|
||||
EditTitle,
|
||||
EditTitleAtStart,
|
||||
RetypeTitle,
|
||||
/// `x`: delete just this node; its children move up.
|
||||
DeleteNode,
|
||||
PasteAfter,
|
||||
PasteBefore,
|
||||
PasteAsChild,
|
||||
Undo,
|
||||
Redo,
|
||||
Repeat,
|
||||
|
||||
// Looks, tags, portals.
|
||||
PickColor,
|
||||
PickStyle,
|
||||
PickBorder,
|
||||
EditTags,
|
||||
NewPortal,
|
||||
Capture,
|
||||
|
||||
// Modes.
|
||||
Visual,
|
||||
VisualBranches,
|
||||
MoveMode,
|
||||
ConnectMode,
|
||||
CommandLine,
|
||||
Escape,
|
||||
|
||||
// In visual, move and connect modes.
|
||||
SwapEnds,
|
||||
FarLeft,
|
||||
FarDown,
|
||||
FarUp,
|
||||
FarRight,
|
||||
ToggleWhole,
|
||||
Confirm,
|
||||
RemoveLink,
|
||||
AttachAsChild,
|
||||
CycleArrows,
|
||||
CycleLine,
|
||||
EditLabel,
|
||||
|
||||
// Panes and documents.
|
||||
SplitVertical,
|
||||
SplitHorizontal,
|
||||
FocusLeft,
|
||||
FocusDown,
|
||||
FocusUp,
|
||||
FocusRight,
|
||||
NextPane,
|
||||
ClosePane,
|
||||
OnlyPane,
|
||||
EqualizePanes,
|
||||
NextDocument,
|
||||
PreviousDocument,
|
||||
PickDocument,
|
||||
ShowCanvas,
|
||||
ShowOutline,
|
||||
ShowNote,
|
||||
|
||||
// Help and the app.
|
||||
Help,
|
||||
ToggleWhichKey,
|
||||
/// `ZZ`: close the pane (everything is saved already).
|
||||
Close,
|
||||
}
|
||||
|
||||
/// What a key sequence is bound to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Bound {
|
||||
/// A command, run as soon as its keys are typed.
|
||||
Command(Command),
|
||||
/// An operator: it waits for a target.
|
||||
Operator(Operator),
|
||||
/// A key that waits for one more character.
|
||||
WithChar(CharKey),
|
||||
}
|
||||
|
||||
/// What a key sequence typed after an operator means.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TargetBound {
|
||||
Target(Target),
|
||||
WithChar(CharKey),
|
||||
}
|
||||
|
||||
/// A command's name in `keys.toml`, and what it does.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CommandInfo {
|
||||
pub name: &'static str,
|
||||
pub bound: Bound,
|
||||
pub help: &'static str,
|
||||
}
|
||||
|
||||
const fn info(name: &'static str, command: Command, help: &'static str) -> CommandInfo {
|
||||
CommandInfo { name, bound: Bound::Command(command), help }
|
||||
}
|
||||
|
||||
const fn motion(name: &'static str, motion: Motion, help: &'static str) -> CommandInfo {
|
||||
info(name, Command::Move(motion), help)
|
||||
}
|
||||
|
||||
const fn operator(name: &'static str, operator: Operator, help: &'static str) -> CommandInfo {
|
||||
CommandInfo { name, bound: Bound::Operator(operator), help }
|
||||
}
|
||||
|
||||
const fn with_char(name: &'static str, key: CharKey, help: &'static str) -> CommandInfo {
|
||||
CommandInfo { name, bound: Bound::WithChar(key), help }
|
||||
}
|
||||
|
||||
use Command as C;
|
||||
use Motion as M;
|
||||
use Operator as Op;
|
||||
|
||||
/// Every command keys can be bound to, by name. A name in `keys.toml` is
|
||||
/// one of these.
|
||||
pub const COMMANDS: &[CommandInfo] = &[
|
||||
// Moving the cursor.
|
||||
motion("left", M::Left, "left"),
|
||||
motion("down", M::Down, "down"),
|
||||
motion("up", M::Up, "up"),
|
||||
motion("right", M::Right, "right"),
|
||||
motion("parent", M::Parent, "the parent"),
|
||||
motion("first-child", M::FirstChild, "the first child"),
|
||||
motion("next-sibling", M::NextSibling, "the next sibling"),
|
||||
motion("previous-sibling", M::PreviousSibling, "the previous sibling"),
|
||||
motion("next", M::Next, "the next node, in reading order"),
|
||||
motion("previous", M::Previous, "the previous node, in reading order"),
|
||||
motion("first", M::First, "the root"),
|
||||
motion("last", M::Last, "the last node"),
|
||||
motion("search-next", M::SearchNext, "the next match"),
|
||||
motion("search-previous", M::SearchPrevious, "the previous match"),
|
||||
with_char("find-forward", CharKey::FindForward, "to a character"),
|
||||
with_char("find-backward", CharKey::FindBackward, "back to a character"),
|
||||
with_char("till-forward", CharKey::TillForward, "up to a character"),
|
||||
with_char("till-backward", CharKey::TillBackward, "back up to a character"),
|
||||
with_char("set-mark", CharKey::SetMark, "mark this node"),
|
||||
with_char("jump-to-mark", CharKey::JumpToMark, "go to a mark"),
|
||||
info("search", C::Search, "search"),
|
||||
info("jump-back", C::JumpBack, "jump back"),
|
||||
info("jump-forward", C::JumpForward, "jump forward"),
|
||||
info("follow-portal", C::FollowPortal, "through the portal"),
|
||||
info("follow-link", C::FollowLink, "follow a cross-link"),
|
||||
info("activate", C::Activate, "edit the title, or go through a portal"),
|
||||
// The view.
|
||||
info("center", C::Center, "center the view"),
|
||||
info("scroll-down", C::ScrollDown, "scroll down"),
|
||||
info("scroll-up", C::ScrollUp, "scroll up"),
|
||||
info("scroll-left", C::ScrollLeft, "scroll left"),
|
||||
info("scroll-right", C::ScrollRight, "scroll right"),
|
||||
info("half-page-down", C::HalfPageDown, "half a screen down"),
|
||||
info("half-page-up", C::HalfPageUp, "half a screen up"),
|
||||
info("zoom-in", C::ZoomIn, "zoom in"),
|
||||
info("zoom-out", C::ZoomOut, "zoom out"),
|
||||
info("fold-toggle", C::ToggleFold, "fold or unfold"),
|
||||
info("fold-open", C::OpenFold, "unfold"),
|
||||
info("fold-close", C::CloseFold, "fold"),
|
||||
info("fold-auto", C::AutoFold, "fold automatically (open while inside)"),
|
||||
info("fold-open-all", C::OpenAllFolds, "unfold everything"),
|
||||
info("fold-close-all", C::CloseAllFolds, "fold everything"),
|
||||
info("focus", C::ToggleFocus, "focus: fold the other branches"),
|
||||
info("show-body", C::ShowBody, "show the body"),
|
||||
// Creating and editing.
|
||||
info("add-below", C::AddBelow, "new sibling below"),
|
||||
info("add-above", C::AddAbove, "new sibling above"),
|
||||
info("add-child", C::AddChild, "new child"),
|
||||
info("edit-title", C::EditTitle, "edit the title"),
|
||||
info("edit-title-start", C::EditTitleAtStart, "edit the title from its start"),
|
||||
info("retype-title", C::RetypeTitle, "retype the title"),
|
||||
info("delete-node", C::DeleteNode, "delete just this node (children move up)"),
|
||||
operator("delete", Op::Delete, "delete…"),
|
||||
operator("yank", Op::Yank, "yank (copy)…"),
|
||||
operator("change", Op::Change, "change…"),
|
||||
operator("indent", Op::Indent, "indent…"),
|
||||
operator("outdent", Op::Outdent, "outdent…"),
|
||||
info("delete-branch", C::Operate(Op::Delete, Target::Branch), "delete the branch"),
|
||||
info("delete-children", C::Operate(Op::Delete, Target::Children), "delete the children"),
|
||||
info("yank-branch", C::Operate(Op::Yank, Target::Branch), "yank the branch"),
|
||||
info("yank-children", C::Operate(Op::Yank, Target::Children), "yank the children"),
|
||||
info("delete-selection", C::Operate(Op::Delete, Target::Selection), "delete the selection"),
|
||||
info("yank-selection", C::Operate(Op::Yank, Target::Selection), "yank the selection"),
|
||||
info("indent-selection", C::Operate(Op::Indent, Target::Selection), "indent the selection"),
|
||||
info("outdent-selection", C::Operate(Op::Outdent, Target::Selection), "outdent the selection"),
|
||||
info("paste-after", C::PasteAfter, "paste after"),
|
||||
info("paste-before", C::PasteBefore, "paste before"),
|
||||
info("paste-as-child", C::PasteAsChild, "paste as a child"),
|
||||
info("undo", C::Undo, "undo"),
|
||||
info("redo", C::Redo, "redo"),
|
||||
info("repeat", C::Repeat, "repeat the last change"),
|
||||
// Looks, tags, portals.
|
||||
info("pick-color", C::PickColor, "color…"),
|
||||
info("pick-style", C::PickStyle, "style…"),
|
||||
info("pick-border", C::PickBorder, "border…"),
|
||||
info("edit-tags", C::EditTags, "tags…"),
|
||||
info("new-portal", C::NewPortal, "new portal…"),
|
||||
info("capture", C::Capture, "capture an idea to the inbox…"),
|
||||
// Modes.
|
||||
info("visual", C::Visual, "select"),
|
||||
info("visual-branches", C::VisualBranches, "select whole branches"),
|
||||
info("move-mode", C::MoveMode, "move mode"),
|
||||
info("connect-mode", C::ConnectMode, "connect mode"),
|
||||
info("command-line", C::CommandLine, "command line"),
|
||||
info("escape", C::Escape, "cancel"),
|
||||
// In visual, move and connect modes.
|
||||
info("swap-ends", C::SwapEnds, "the other end of the selection"),
|
||||
info("far-left", C::FarLeft, "left by five"),
|
||||
info("far-down", C::FarDown, "down by five"),
|
||||
info("far-up", C::FarUp, "up by five"),
|
||||
info("far-right", C::FarRight, "right by five"),
|
||||
info("toggle-whole", C::ToggleWhole, "the whole branch, or just this node"),
|
||||
info("confirm", C::Confirm, "confirm"),
|
||||
info("remove-link", C::RemoveLink, "remove the link"),
|
||||
info("attach-as-child", C::AttachAsChild, "attach as a child here"),
|
||||
info("cycle-arrows", C::CycleArrows, "arrowheads"),
|
||||
info("cycle-line", C::CycleLine, "line kind"),
|
||||
info("edit-label", C::EditLabel, "label…"),
|
||||
// Panes and documents.
|
||||
info("split-vertical", C::SplitVertical, "split side by side"),
|
||||
info("split-horizontal", C::SplitHorizontal, "split above and below"),
|
||||
info("focus-left", C::FocusLeft, "the pane on the left"),
|
||||
info("focus-down", C::FocusDown, "the pane below"),
|
||||
info("focus-up", C::FocusUp, "the pane above"),
|
||||
info("focus-right", C::FocusRight, "the pane on the right"),
|
||||
info("next-pane", C::NextPane, "the next pane"),
|
||||
info("close-pane", C::ClosePane, "close the pane"),
|
||||
info("only-pane", C::OnlyPane, "close the other panes"),
|
||||
info("equalize-panes", C::EqualizePanes, "even out the panes"),
|
||||
info("next-document", C::NextDocument, "the next document"),
|
||||
info("previous-document", C::PreviousDocument, "the previous document"),
|
||||
info("pick-document", C::PickDocument, "documents…"),
|
||||
info("show-canvas", C::ShowCanvas, "canvas"),
|
||||
info("show-outline", C::ShowOutline, "outline"),
|
||||
info("show-note", C::ShowNote, "note"),
|
||||
// Help and the app.
|
||||
info("help", C::Help, "help: every key"),
|
||||
info("toggle-which-key", C::ToggleWhichKey, "key hints on or off"),
|
||||
info("close", C::Close, "close the pane"),
|
||||
];
|
||||
|
||||
/// The command with this name in `keys.toml`.
|
||||
pub fn by_name(name: &str) -> Option<&'static CommandInfo> {
|
||||
COMMANDS.iter().find(|info| info.name == name)
|
||||
}
|
||||
|
||||
/// What a binding does, in words (for which-key and the help).
|
||||
pub fn help_for(bound: Bound) -> &'static str {
|
||||
COMMANDS.iter().find(|info| info.bound == bound).map_or("", |info| info.help)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_are_unique_kebab_case() {
|
||||
for (index, info) in COMMANDS.iter().enumerate() {
|
||||
assert!(info.name.chars().all(|c| c.is_ascii_lowercase() || c == '-'), "{}", info.name);
|
||||
assert!(COMMANDS[index + 1..].iter().all(|other| other.name != info.name), "{} twice", info.name);
|
||||
assert!(
|
||||
COMMANDS[index + 1..].iter().all(|other| other.bound != info.bound),
|
||||
"{:?} twice",
|
||||
info.bound
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registers_by_name() {
|
||||
assert_eq!(Register::named('A'), Some(Register::Named('a')));
|
||||
assert_eq!(Register::named('*'), Some(Register::Clipboard));
|
||||
assert_eq!(Register::named('1'), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! The key engine reads key presses the way vim does. It collects a count
|
||||
//! (`5j`), a register (`"a`), a sequence (`gg`), an operator and its target
|
||||
//! (`dab`), or a key and the character after it (`ma`), and hands over a
|
||||
//! complete [`Typed`] command. It also says what can come next, for the
|
||||
//! which-key popup ([`KeyEngine::hints`]).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::command::{Bound, CharKey, Command, Operator, Register, Target, TargetBound};
|
||||
use super::key::{Key, notation};
|
||||
use super::keymap::{Binding, Tables};
|
||||
|
||||
/// The biggest count. `99999j` would only go somewhere silly.
|
||||
const MAX_COUNT: u32 = 9999;
|
||||
|
||||
/// A complete command, with what was typed around it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Typed {
|
||||
pub command: Command,
|
||||
/// The number typed first, like the 5 in `5j`. Around an operator, both
|
||||
/// counts multiply: `2d3w` is 6.
|
||||
pub count: Option<u32>,
|
||||
/// The register named with `"`, like `a` in `"ayy`.
|
||||
pub register: Option<Register>,
|
||||
}
|
||||
|
||||
impl Typed {
|
||||
/// How many times to do it: the count, or once.
|
||||
pub fn times(&self) -> u32 {
|
||||
self.count.unwrap_or(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a key press led to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// A complete command.
|
||||
Run(Typed),
|
||||
/// The keys so far start something: waiting for the next key.
|
||||
Waiting,
|
||||
/// Esc dropped what was being typed.
|
||||
Cancelled,
|
||||
/// Nothing starts like this. The keys typed (in notation) were dropped.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// What the engine waits for next.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
enum Stage {
|
||||
/// A command's keys: none yet, or the start of a sequence like `g`.
|
||||
#[default]
|
||||
Command,
|
||||
/// A register's name, after `"`.
|
||||
Register,
|
||||
/// The character after a key like `m`.
|
||||
Char(CharKey),
|
||||
/// An operator's target, like `ab` after `d`. `count` is the one typed
|
||||
/// before the operator.
|
||||
Target { operator: Operator, operator_keys: Vec<Key>, count: Option<u32> },
|
||||
/// The character of a target like `f` (`dfx`), with the counts so far.
|
||||
TargetChar { operator: Operator, kind: CharKey, count: Option<u32> },
|
||||
}
|
||||
|
||||
/// Reads key presses into commands. It remembers what's half typed in
|
||||
/// between.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KeyEngine {
|
||||
stage: Stage,
|
||||
/// The count being typed: before the command, or before an operator's
|
||||
/// target.
|
||||
count: Option<u32>,
|
||||
register: Option<Register>,
|
||||
/// The keys typed so far in this stage.
|
||||
keys: Vec<Key>,
|
||||
/// Everything typed so far, as vim's `showcmd` shows it: `2"a`, `dg`.
|
||||
shown: String,
|
||||
/// When the first key of the sequence was pressed (for which-key's delay).
|
||||
since: Option<Instant>,
|
||||
}
|
||||
|
||||
/// What can follow the keys typed so far, for the which-key popup.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Hints {
|
||||
/// The keys typed and what they lead to: `g · go to`.
|
||||
pub title: String,
|
||||
pub entries: Vec<Hint>,
|
||||
}
|
||||
|
||||
/// One possible next key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Hint {
|
||||
pub keys: String,
|
||||
pub help: String,
|
||||
/// Whether the key opens a group of more keys (shown as `+name`).
|
||||
pub group: bool,
|
||||
}
|
||||
|
||||
impl KeyEngine {
|
||||
/// Reads one key press, using the tables in effect.
|
||||
pub fn press(&mut self, key: Key, tables: &Tables<'_>) -> Outcome {
|
||||
if key.is_escape() && self.is_pending() {
|
||||
self.reset();
|
||||
return Outcome::Cancelled;
|
||||
}
|
||||
self.shown.push_str(&key.to_string());
|
||||
match std::mem::take(&mut self.stage) {
|
||||
Stage::Command => self.command_key(key, tables),
|
||||
Stage::Register => match key.as_char().and_then(Register::named) {
|
||||
Some(register) => {
|
||||
self.register = Some(register);
|
||||
Outcome::Waiting
|
||||
}
|
||||
None => self.unknown(),
|
||||
},
|
||||
Stage::Char(kind) => match key.as_char() {
|
||||
Some(c) => self.run(kind.command(c)),
|
||||
None => self.unknown(),
|
||||
},
|
||||
Stage::Target { operator, operator_keys, count } => {
|
||||
self.target_key(key, tables, operator, operator_keys, count)
|
||||
}
|
||||
Stage::TargetChar { operator, kind, count } => match key.as_char().and_then(|c| kind.target(c)) {
|
||||
Some(target) => {
|
||||
self.count = count;
|
||||
self.run(Command::Operate(operator, target))
|
||||
}
|
||||
None => self.unknown(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets anything half typed.
|
||||
pub fn reset(&mut self) {
|
||||
*self = KeyEngine::default();
|
||||
}
|
||||
|
||||
/// Whether something is half typed: a count, a register, a sequence…
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.stage != Stage::Command
|
||||
|| !self.keys.is_empty()
|
||||
|| self.count.is_some()
|
||||
|| self.register.is_some()
|
||||
}
|
||||
|
||||
/// The keys typed so far, like vim's `showcmd`: `2"ad`.
|
||||
pub fn shown(&self) -> &str {
|
||||
&self.shown
|
||||
}
|
||||
|
||||
/// When the sequence being typed started (counts don't count), for
|
||||
/// which-key's delay.
|
||||
pub fn pending_since(&self) -> Option<Instant> {
|
||||
self.since
|
||||
}
|
||||
|
||||
/// The first key of the sequence being typed (to show the leader menu
|
||||
/// right away).
|
||||
pub fn first_key(&self) -> Option<Key> {
|
||||
match &self.stage {
|
||||
Stage::Target { operator_keys, .. } => operator_keys.first().copied(),
|
||||
_ => self.keys.first().copied(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What can be typed next, for the which-key popup. `None` while nothing
|
||||
/// that has follow-ups is half typed.
|
||||
pub fn hints(&self, tables: &Tables<'_>) -> Option<Hints> {
|
||||
let fixed = |what: &str, choices: &[(&str, &str)]| Hints {
|
||||
title: format!("{} · {what}", self.shown),
|
||||
entries: choices
|
||||
.iter()
|
||||
.map(|(keys, help)| Hint { keys: keys.to_string(), help: help.to_string(), group: false })
|
||||
.collect(),
|
||||
};
|
||||
match &self.stage {
|
||||
Stage::Register => Some(fixed("register", Register::HINTS)),
|
||||
Stage::Char(kind) | Stage::TargetChar { kind, .. } => {
|
||||
Some(fixed("then a character", kind.hints()))
|
||||
}
|
||||
Stage::Command if self.keys.is_empty() => None,
|
||||
Stage::Command => {
|
||||
let title = match tables.group_name(&self.keys) {
|
||||
Some(name) => format!("{} · {name}", self.shown),
|
||||
None => self.shown.clone(),
|
||||
};
|
||||
let entries = next_keys(&self.keys, &tables.commands_starting_with(&self.keys), tables);
|
||||
Some(Hints { title, entries })
|
||||
}
|
||||
Stage::Target { operator, operator_keys, .. } => {
|
||||
let mut entries = Vec::new();
|
||||
if self.keys.is_empty() {
|
||||
let help = "this node, with its branch".to_string();
|
||||
entries.push(Hint { keys: notation(operator_keys), help, group: false });
|
||||
}
|
||||
let targets = tables.targets_starting_with(&self.keys);
|
||||
for target in targets {
|
||||
let next = &target.keys[..=self.keys.len()];
|
||||
let keys = target.keys[self.keys.len()].to_string();
|
||||
if entries.iter().any(|hint| hint.keys == keys) {
|
||||
continue;
|
||||
}
|
||||
let hint = if target.keys.len() == next.len() {
|
||||
Hint { keys, help: target.help.clone(), group: false }
|
||||
} else {
|
||||
let name = tables.target_group_name(next).unwrap_or("more");
|
||||
Hint { keys, help: format!("+{name}"), group: true }
|
||||
};
|
||||
entries.push(hint);
|
||||
}
|
||||
let group =
|
||||
tables.target_group_name(&self.keys).map(|name| format!(" {name}")).unwrap_or_default();
|
||||
Some(Hints { title: format!("{} · {}{group}", self.shown, operator.name()), entries })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A key while waiting for a command.
|
||||
fn command_key(&mut self, key: Key, tables: &Tables<'_>) -> Outcome {
|
||||
if self.keys.is_empty() {
|
||||
if let Some(count) = with_digit(self.count, key) {
|
||||
self.count = Some(count);
|
||||
return Outcome::Waiting;
|
||||
}
|
||||
self.since.get_or_insert_with(Instant::now);
|
||||
if key.as_char() == Some('"') && self.register.is_none() {
|
||||
self.stage = Stage::Register;
|
||||
return Outcome::Waiting;
|
||||
}
|
||||
}
|
||||
self.keys.push(key);
|
||||
let candidates = tables.commands_starting_with(&self.keys);
|
||||
let Some(exact) = candidates.iter().find(|binding| binding.keys == self.keys) else {
|
||||
return if candidates.is_empty() { self.unknown() } else { Outcome::Waiting };
|
||||
};
|
||||
match exact.bound {
|
||||
Bound::Command(command) => self.run(command),
|
||||
Bound::Operator(operator) => {
|
||||
let operator_keys = std::mem::take(&mut self.keys);
|
||||
self.stage = Stage::Target { operator, operator_keys, count: self.count.take() };
|
||||
Outcome::Waiting
|
||||
}
|
||||
Bound::WithChar(kind) => {
|
||||
self.keys.clear();
|
||||
self.stage = Stage::Char(kind);
|
||||
Outcome::Waiting
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A key while an operator waits for its target.
|
||||
fn target_key(
|
||||
&mut self,
|
||||
key: Key,
|
||||
tables: &Tables<'_>,
|
||||
operator: Operator,
|
||||
operator_keys: Vec<Key>,
|
||||
count: Option<u32>,
|
||||
) -> Outcome {
|
||||
if self.keys.is_empty()
|
||||
&& let Some(target_count) = with_digit(self.count, key)
|
||||
{
|
||||
self.count = Some(target_count);
|
||||
self.stage = Stage::Target { operator, operator_keys, count };
|
||||
return Outcome::Waiting;
|
||||
}
|
||||
self.keys.push(key);
|
||||
// The operator's own keys again: this node (or line), as in `dd`.
|
||||
if self.keys == operator_keys {
|
||||
self.count = multiply(count, self.count);
|
||||
return self.run(Command::Operate(operator, Target::Current));
|
||||
}
|
||||
let candidates = tables.targets_starting_with(&self.keys);
|
||||
if let Some(exact) = candidates.iter().find(|target| target.keys == self.keys) {
|
||||
return match exact.bound {
|
||||
TargetBound::Target(target) => {
|
||||
self.count = multiply(count, self.count);
|
||||
self.run(Command::Operate(operator, target))
|
||||
}
|
||||
TargetBound::WithChar(kind) => {
|
||||
self.keys.clear();
|
||||
let count = multiply(count, self.count.take());
|
||||
self.stage = Stage::TargetChar { operator, kind, count };
|
||||
Outcome::Waiting
|
||||
}
|
||||
};
|
||||
}
|
||||
if candidates.is_empty() && !operator_keys.starts_with(&self.keys) {
|
||||
return self.unknown();
|
||||
}
|
||||
self.stage = Stage::Target { operator, operator_keys, count };
|
||||
Outcome::Waiting
|
||||
}
|
||||
|
||||
fn run(&mut self, command: Command) -> Outcome {
|
||||
let typed = Typed { command, count: self.count, register: self.register };
|
||||
self.reset();
|
||||
Outcome::Run(typed)
|
||||
}
|
||||
|
||||
fn unknown(&mut self) -> Outcome {
|
||||
let shown = std::mem::take(&mut self.shown);
|
||||
self.reset();
|
||||
Outcome::Unknown(shown)
|
||||
}
|
||||
}
|
||||
|
||||
/// The count with one more digit typed, if `key` is a digit that continues
|
||||
/// or starts a count (`0` only continues one: alone, it's a key of its own).
|
||||
fn with_digit(count: Option<u32>, key: Key) -> Option<u32> {
|
||||
let digit = key.as_char()?.to_digit(10)?;
|
||||
if digit == 0 && count.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some((count.unwrap_or(0) * 10 + digit).min(MAX_COUNT))
|
||||
}
|
||||
|
||||
/// Two counts typed around an operator multiply (`2d3w` deletes 6 words).
|
||||
fn multiply(before: Option<u32>, after: Option<u32>) -> Option<u32> {
|
||||
match (before, after) {
|
||||
(None, None) => None,
|
||||
_ => Some((before.unwrap_or(1) * after.unwrap_or(1)).min(MAX_COUNT)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys that can follow `typed`, one entry per next key: what it does,
|
||||
/// or `+group` when more keys follow.
|
||||
fn next_keys(typed: &[Key], candidates: &[&Binding], tables: &Tables<'_>) -> Vec<Hint> {
|
||||
let mut hints: Vec<Hint> = Vec::new();
|
||||
for binding in candidates {
|
||||
let Some(next) = binding.keys.get(typed.len()) else { continue };
|
||||
let keys = next.to_string();
|
||||
if hints.iter().any(|hint| hint.keys == keys) {
|
||||
continue;
|
||||
}
|
||||
let hint = if binding.keys.len() == typed.len() + 1 {
|
||||
Hint { keys, help: binding.help.clone(), group: false }
|
||||
} else {
|
||||
let name = tables.group_name(&binding.keys[..=typed.len()]).unwrap_or("more");
|
||||
Hint { keys, help: format!("+{name}"), group: true }
|
||||
};
|
||||
hints.push(hint);
|
||||
}
|
||||
hints
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::command::Motion;
|
||||
use crate::input::key::parse_keys;
|
||||
use crate::input::keymap::{DEFAULT_LEADER, Keymap};
|
||||
|
||||
const NORMAL: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions];
|
||||
|
||||
/// Types `text` (vim notation) and returns what the last key led to.
|
||||
fn type_keys(engine: &mut KeyEngine, keymap: &Keymap, text: &str) -> Outcome {
|
||||
let tables = keymap.tables(NORMAL);
|
||||
let mut last = Outcome::Waiting;
|
||||
for key in parse_keys(text, keymap.leader()).unwrap() {
|
||||
last = engine.press(key, &tables);
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
fn run(command: Command, count: Option<u32>, register: Option<Register>) -> Outcome {
|
||||
Outcome::Run(Typed { command, count, register })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequences_counts_and_registers() {
|
||||
let keymap = Keymap::new(DEFAULT_LEADER);
|
||||
let mut engine = KeyEngine::default();
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "g"), Outcome::Waiting);
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "g"), run(Command::Move(Motion::First), None, None));
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "12j"), run(Command::Move(Motion::Down), Some(12), None));
|
||||
assert_eq!(
|
||||
type_keys(&mut engine, &keymap, "\"+p"),
|
||||
run(Command::PasteAfter, None, Some(Register::Clipboard))
|
||||
);
|
||||
assert_eq!(
|
||||
type_keys(&mut engine, &keymap, "3\"ap"),
|
||||
run(Command::PasteAfter, Some(3), Some(Register::Named('a')))
|
||||
);
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "gq"), Outcome::Unknown("gq".into()));
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "z<Esc>"), Outcome::Cancelled);
|
||||
assert!(!engine.is_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators_wait_for_their_target() {
|
||||
let keymap = Keymap::new(DEFAULT_LEADER);
|
||||
let mut engine = KeyEngine::default();
|
||||
let delete = |target, count| run(Command::Operate(Operator::Delete, target), count, None);
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "dd"), delete(Target::Current, None));
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "dab"), delete(Target::Branch, None));
|
||||
assert_eq!(
|
||||
type_keys(&mut engine, &keymap, "2d3d"),
|
||||
delete(Target::Current, Some(6)),
|
||||
"counts multiply"
|
||||
);
|
||||
assert_eq!(
|
||||
type_keys(&mut engine, &keymap, ">>"),
|
||||
run(Command::Operate(Operator::Indent, Target::Current), None, None)
|
||||
);
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "dx"), Outcome::Unknown("dx".into()));
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "ma"), run(Command::SetMark('a'), None, None));
|
||||
assert_eq!(type_keys(&mut engine, &keymap, "'A"), run(Command::JumpToMark('A'), None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn which_key_lists_what_can_follow() {
|
||||
let keymap = Keymap::new(DEFAULT_LEADER);
|
||||
let tables = keymap.tables(NORMAL);
|
||||
let mut engine = KeyEngine::default();
|
||||
assert_eq!(engine.hints(&tables), None);
|
||||
|
||||
type_keys(&mut engine, &keymap, "<Space>");
|
||||
let hints = engine.hints(&tables).unwrap();
|
||||
assert_eq!(hints.title, "<Space> · actions");
|
||||
assert!(hints.entries.contains(&Hint { keys: "c".into(), help: "color…".into(), group: false }));
|
||||
assert!(hints.entries.contains(&Hint { keys: "v".into(), help: "+show a view".into(), group: true }));
|
||||
assert_eq!(engine.first_key(), Some(DEFAULT_LEADER));
|
||||
|
||||
engine.reset();
|
||||
type_keys(&mut engine, &keymap, "d");
|
||||
let hints = engine.hints(&tables).unwrap();
|
||||
assert_eq!(hints.title, "d · delete");
|
||||
let keys: Vec<&str> = hints.entries.iter().map(|hint| hint.keys.as_str()).collect();
|
||||
assert_eq!(keys, ["d", "a", "i"]);
|
||||
assert!(hints.entries[1].group);
|
||||
|
||||
engine.reset();
|
||||
type_keys(&mut engine, &keymap, "\"");
|
||||
assert_eq!(engine.hints(&tables).unwrap().title, "\" · register");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! One key press, and vim's notation for writing keys down:
|
||||
//!
|
||||
//! - letters and symbols as they are: `j`, `G`, `gg`, `:`, `<<`
|
||||
//! - special keys in angle brackets: `<CR>` (Enter), `<Esc>`, `<Tab>`,
|
||||
//! `<S-Tab>`, `<BS>`, `<Del>`, `<Space>`, `<Up>`, `<Down>`, `<Left>`,
|
||||
//! `<Right>`, `<Home>`, `<End>`, `<PageUp>`, `<PageDown>`, `<F1>`…`<F12>`,
|
||||
//! and `<lt>` for the `<` key itself
|
||||
//! - with Ctrl or Alt: `<C-w>`, `<A-j>` (or `<M-j>`), `<C-Up>`
|
||||
//! - `<leader>`: the leader key, `<Space>` unless keys.toml says otherwise
|
||||
//!
|
||||
//! Names in angle brackets ignore case: `<cr>` is `<CR>`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use thiserror::Error;
|
||||
|
||||
/// A key on the keyboard, without Ctrl or Alt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum KeyName {
|
||||
/// A key that types a character. Shift is part of the character: `G`
|
||||
/// is Shift+g.
|
||||
Char(char),
|
||||
Enter,
|
||||
Esc,
|
||||
Tab,
|
||||
/// Shift+Tab.
|
||||
BackTab,
|
||||
Backspace,
|
||||
Delete,
|
||||
Insert,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
/// A function key, `F1` to `F12`.
|
||||
F(u8),
|
||||
}
|
||||
|
||||
/// One key press: a key, and whether Ctrl or Alt was held.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Key {
|
||||
pub name: KeyName,
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// A key pressed on its own.
|
||||
pub const fn plain(name: KeyName) -> Key {
|
||||
Key { name, ctrl: false, alt: false }
|
||||
}
|
||||
|
||||
/// A character key pressed on its own.
|
||||
pub const fn char(c: char) -> Key {
|
||||
Key::plain(KeyName::Char(c))
|
||||
}
|
||||
|
||||
/// The key from a terminal event, or `None` for keys cumulus doesn't use
|
||||
/// (media keys, a modifier pressed alone…).
|
||||
pub fn from_event(event: KeyEvent) -> Option<Key> {
|
||||
let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = event.modifiers.contains(KeyModifiers::ALT);
|
||||
let name = match event.code {
|
||||
// Ctrl+Shift+a and Ctrl+a are the same key for cumulus.
|
||||
KeyCode::Char(c) if ctrl => KeyName::Char(c.to_ascii_lowercase()),
|
||||
KeyCode::Char(c) => KeyName::Char(c),
|
||||
KeyCode::Enter => KeyName::Enter,
|
||||
KeyCode::Esc => KeyName::Esc,
|
||||
KeyCode::Tab => KeyName::Tab,
|
||||
KeyCode::BackTab => KeyName::BackTab,
|
||||
KeyCode::Backspace => KeyName::Backspace,
|
||||
KeyCode::Delete => KeyName::Delete,
|
||||
KeyCode::Insert => KeyName::Insert,
|
||||
KeyCode::Up => KeyName::Up,
|
||||
KeyCode::Down => KeyName::Down,
|
||||
KeyCode::Left => KeyName::Left,
|
||||
KeyCode::Right => KeyName::Right,
|
||||
KeyCode::Home => KeyName::Home,
|
||||
KeyCode::End => KeyName::End,
|
||||
KeyCode::PageUp => KeyName::PageUp,
|
||||
KeyCode::PageDown => KeyName::PageDown,
|
||||
KeyCode::F(number) => KeyName::F(number),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Key { name, ctrl, alt })
|
||||
}
|
||||
|
||||
/// The character a plain character key types.
|
||||
pub fn as_char(self) -> Option<char> {
|
||||
match self {
|
||||
Key { name: KeyName::Char(c), ctrl: false, alt: false } => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this key cancels, like Esc. Vim treats Ctrl-c and Ctrl-[ the
|
||||
/// same way.
|
||||
pub fn is_escape(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Key { name: KeyName::Esc, ctrl: false, alt: false }
|
||||
| Key { name: KeyName::Char('c' | '['), ctrl: true, alt: false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Key {
|
||||
/// The key in vim notation, as which-key and the help show it. The `<`
|
||||
/// key shows as itself, so `<<` reads naturally.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let name = match self.name {
|
||||
KeyName::Char(' ') => "Space".to_string(),
|
||||
KeyName::Char(c) if !self.ctrl && !self.alt => return write!(f, "{c}"),
|
||||
KeyName::Char(c) => c.to_string(),
|
||||
KeyName::Enter => "CR".into(),
|
||||
KeyName::Esc => "Esc".into(),
|
||||
KeyName::Tab => "Tab".into(),
|
||||
KeyName::BackTab => "S-Tab".into(),
|
||||
KeyName::Backspace => "BS".into(),
|
||||
KeyName::Delete => "Del".into(),
|
||||
KeyName::Insert => "Ins".into(),
|
||||
KeyName::Up => "Up".into(),
|
||||
KeyName::Down => "Down".into(),
|
||||
KeyName::Left => "Left".into(),
|
||||
KeyName::Right => "Right".into(),
|
||||
KeyName::Home => "Home".into(),
|
||||
KeyName::End => "End".into(),
|
||||
KeyName::PageUp => "PageUp".into(),
|
||||
KeyName::PageDown => "PageDown".into(),
|
||||
KeyName::F(number) => format!("F{number}"),
|
||||
};
|
||||
let ctrl = if self.ctrl { "C-" } else { "" };
|
||||
let alt = if self.alt { "A-" } else { "" };
|
||||
write!(f, "<{ctrl}{alt}{name}>")
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys written back in notation: `<C-w>v`.
|
||||
pub fn notation(keys: &[Key]) -> String {
|
||||
keys.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
/// Why keys written in notation couldn't be read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum NotationError {
|
||||
#[error("no keys")]
|
||||
Empty,
|
||||
#[error("there's no key called <{0}>")]
|
||||
UnknownKey(String),
|
||||
}
|
||||
|
||||
/// Reads keys written in vim notation. `leader` is the key `<leader>`
|
||||
/// stands for.
|
||||
pub fn parse_keys(text: &str, leader: Key) -> Result<Vec<Key>, NotationError> {
|
||||
let mut keys = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
// `<` starts a key name when one follows (`<CR>`); otherwise it's the
|
||||
// `<` key itself, as in `<<`.
|
||||
if c == '<'
|
||||
&& let Some(end) = rest.find('>')
|
||||
&& looks_like_a_name(&rest[1..end])
|
||||
{
|
||||
keys.push(named_key(&rest[1..end], leader)?);
|
||||
rest = &rest[end + 1..];
|
||||
} else {
|
||||
keys.push(Key::char(c));
|
||||
rest = &rest[c.len_utf8()..];
|
||||
}
|
||||
}
|
||||
if keys.is_empty() { Err(NotationError::Empty) } else { Ok(keys) }
|
||||
}
|
||||
|
||||
/// Whether the text between `<` and `>` is meant as a key name, so a typo
|
||||
/// like `<Spcae>` is reported instead of read as six keys.
|
||||
fn looks_like_a_name(text: &str) -> bool {
|
||||
!text.is_empty() && text.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
}
|
||||
|
||||
/// A key from its name in angle brackets, like `C-w` or `Space`.
|
||||
fn named_key(name: &str, leader: Key) -> Result<Key, NotationError> {
|
||||
if name.eq_ignore_ascii_case("leader") {
|
||||
return Ok(leader);
|
||||
}
|
||||
let (mut ctrl, mut alt) = (false, false);
|
||||
let mut base = name;
|
||||
while base.len() > 2 && base.as_bytes()[1] == b'-' {
|
||||
match base.as_bytes()[0].to_ascii_lowercase() {
|
||||
b'c' => ctrl = true,
|
||||
b'a' | b'm' => alt = true,
|
||||
_ => break,
|
||||
}
|
||||
base = &base[2..];
|
||||
}
|
||||
let unknown = || NotationError::UnknownKey(name.to_string());
|
||||
let lower = base.to_ascii_lowercase();
|
||||
let key_name = match lower.as_str() {
|
||||
"cr" | "enter" | "return" => KeyName::Enter,
|
||||
"esc" => KeyName::Esc,
|
||||
"tab" => KeyName::Tab,
|
||||
"s-tab" => KeyName::BackTab,
|
||||
"bs" | "backspace" => KeyName::Backspace,
|
||||
"del" | "delete" => KeyName::Delete,
|
||||
"ins" | "insert" => KeyName::Insert,
|
||||
"space" => KeyName::Char(' '),
|
||||
"lt" => KeyName::Char('<'),
|
||||
"bar" => KeyName::Char('|'),
|
||||
"bslash" => KeyName::Char('\\'),
|
||||
"up" => KeyName::Up,
|
||||
"down" => KeyName::Down,
|
||||
"left" => KeyName::Left,
|
||||
"right" => KeyName::Right,
|
||||
"home" => KeyName::Home,
|
||||
"end" => KeyName::End,
|
||||
"pageup" => KeyName::PageUp,
|
||||
"pagedown" => KeyName::PageDown,
|
||||
_ => {
|
||||
let mut chars = base.chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(c), None) if ctrl => KeyName::Char(c.to_ascii_lowercase()),
|
||||
(Some(c), None) => KeyName::Char(c),
|
||||
_ => match lower.strip_prefix('f').and_then(|number| number.parse::<u8>().ok()) {
|
||||
Some(number @ 1..=12) => KeyName::F(number),
|
||||
_ => return Err(unknown()),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Key { name: key_name, ctrl, alt })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SPACE: Key = Key::char(' ');
|
||||
|
||||
fn keys(text: &str) -> Vec<Key> {
|
||||
parse_keys(text, SPACE).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notation_reads_and_writes_back() {
|
||||
for text in
|
||||
["gg", "<C-w>v", "<Space>c", "<CR>", "<S-Tab>", "ZZ", "<<", "<A-j>", "<C-Up>", "<F5>", "]p"]
|
||||
{
|
||||
assert_eq!(notation(&keys(text)), text, "{text}");
|
||||
}
|
||||
assert_eq!(keys("<lt>"), keys("<"));
|
||||
assert_eq!(keys("<cr>"), keys("<CR>"), "names ignore case");
|
||||
assert_eq!(keys("<C-W>"), keys("<C-w>"), "Ctrl letters are lowercase");
|
||||
assert_eq!(keys("<M-j>"), keys("<A-j>"));
|
||||
assert_eq!(keys("<leader>w"), keys("<Space>w"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typos_are_reported() {
|
||||
assert_eq!(parse_keys("<Spcae>", SPACE), Err(NotationError::UnknownKey("Spcae".into())));
|
||||
assert_eq!(parse_keys("<F13>", SPACE), Err(NotationError::UnknownKey("F13".into())));
|
||||
assert_eq!(parse_keys("", SPACE), Err(NotationError::Empty));
|
||||
assert_eq!(keys("< >").len(), 3, "not a name: three keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_events_become_keys() {
|
||||
let event = |code, modifiers| Key::from_event(KeyEvent::new(code, modifiers));
|
||||
assert_eq!(
|
||||
event(KeyCode::Char('W'), KeyModifiers::CONTROL | KeyModifiers::SHIFT),
|
||||
Some(keys("<C-w>")[0])
|
||||
);
|
||||
assert_eq!(event(KeyCode::Char('G'), KeyModifiers::SHIFT), Some(Key::char('G')));
|
||||
assert!(event(KeyCode::Esc, KeyModifiers::NONE).is_some_and(Key::is_escape));
|
||||
assert!(event(KeyCode::Char('c'), KeyModifiers::CONTROL).is_some_and(Key::is_escape));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! The keys in effect: the default tables from [`bindings`](super::bindings),
|
||||
//! with your changes from `keys.toml`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! leader = "<Space>" # the key <leader> stands for
|
||||
//!
|
||||
//! [normal] # a section per place: motions, normal, canvas,
|
||||
//! "<leader>x" = "delete-branch" # outline, note, visual, move, connect, list
|
||||
//! "Q" = "close"
|
||||
//! "x" = "nothing" # "nothing" removes a default key
|
||||
//! ```
|
||||
//!
|
||||
//! Mistakes don't stop the rest of the file from working: each one is skipped
|
||||
//! and reported in words.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::bindings::{Context, GROUPS, NODE_TARGETS, SITUATIONS, TARGET_GROUPS};
|
||||
use super::command::{self, Bound, COMMANDS, TargetBound};
|
||||
use super::key::{Key, notation, parse_keys};
|
||||
|
||||
/// The leader key unless `keys.toml` names another.
|
||||
pub const DEFAULT_LEADER: Key = Key::char(' ');
|
||||
|
||||
/// Keys bound to something, and what that does in words.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Binding {
|
||||
pub keys: Vec<Key>,
|
||||
pub bound: Bound,
|
||||
pub help: String,
|
||||
}
|
||||
|
||||
/// Keys typed after an operator, and what they make it act on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TargetBinding {
|
||||
pub keys: Vec<Key>,
|
||||
pub bound: TargetBound,
|
||||
pub help: String,
|
||||
}
|
||||
|
||||
/// Which keys do what, everywhere.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Keymap {
|
||||
leader: Key,
|
||||
tables: BTreeMap<Context, Vec<Binding>>,
|
||||
targets: Vec<TargetBinding>,
|
||||
groups: Vec<(Vec<Key>, &'static str)>,
|
||||
target_groups: Vec<(Vec<Key>, &'static str)>,
|
||||
}
|
||||
|
||||
/// The tables in effect at one moment, most specific first, and what an
|
||||
/// operator can act on there.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Tables<'a> {
|
||||
keymap: &'a Keymap,
|
||||
contexts: &'a [Context],
|
||||
}
|
||||
|
||||
impl Keymap {
|
||||
/// The default keys, with `leader` as the leader key.
|
||||
pub fn new(leader: Key) -> Keymap {
|
||||
let parse = |text: &str| parse_keys(text, leader).ok();
|
||||
let tables = Context::ALL
|
||||
.into_iter()
|
||||
.map(|context| {
|
||||
let bindings = context
|
||||
.defaults()
|
||||
.iter()
|
||||
.filter_map(|default| {
|
||||
let help = default.help.unwrap_or_else(|| command::help_for(default.bound));
|
||||
Some(Binding {
|
||||
keys: parse(default.keys)?,
|
||||
bound: default.bound,
|
||||
help: help.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(context, bindings)
|
||||
})
|
||||
.collect();
|
||||
let targets = NODE_TARGETS
|
||||
.iter()
|
||||
.filter_map(|(keys, bound, help)| {
|
||||
Some(TargetBinding { keys: parse(keys)?, bound: *bound, help: help.to_string() })
|
||||
})
|
||||
.collect();
|
||||
let named = |groups: &[(&str, &'static str)]| -> Vec<(Vec<Key>, &'static str)> {
|
||||
groups.iter().filter_map(|(keys, name)| Some((parse(keys)?, *name))).collect()
|
||||
};
|
||||
Keymap { leader, tables, targets, groups: named(GROUPS), target_groups: named(TARGET_GROUPS) }
|
||||
}
|
||||
|
||||
/// The default keys, changed by the text of a `keys.toml` file. Returns
|
||||
/// the keymap and the problems found, in words. A problem only drops the
|
||||
/// line it's on.
|
||||
pub fn from_toml(text: &str) -> (Keymap, Vec<String>) {
|
||||
let mut problems = Vec::new();
|
||||
let file: toml::Table = match toml::from_str(text) {
|
||||
Ok(file) => file,
|
||||
Err(error) => {
|
||||
return (Keymap::new(DEFAULT_LEADER), vec![format!("keys.toml isn't valid TOML: {error}")]);
|
||||
}
|
||||
};
|
||||
// The leader comes first: every `<leader>` key depends on it.
|
||||
let leader = match file.get("leader") {
|
||||
None => DEFAULT_LEADER,
|
||||
Some(toml::Value::String(text)) => match parse_keys(text, DEFAULT_LEADER).as_deref() {
|
||||
Ok([key]) => *key,
|
||||
Ok(_) => {
|
||||
problems.push(format!("leader = {text:?}: the leader is one key"));
|
||||
DEFAULT_LEADER
|
||||
}
|
||||
Err(error) => {
|
||||
problems.push(format!("leader = {text:?}: {error}"));
|
||||
DEFAULT_LEADER
|
||||
}
|
||||
},
|
||||
Some(_) => {
|
||||
problems.push("leader should be a key in quotes, like \"<Space>\"".into());
|
||||
DEFAULT_LEADER
|
||||
}
|
||||
};
|
||||
let mut keymap = Keymap::new(leader);
|
||||
for (section, value) in &file {
|
||||
if section == "leader" {
|
||||
continue;
|
||||
}
|
||||
let Some(context) = Context::named(section) else {
|
||||
let sections: Vec<&str> = Context::ALL.iter().map(|context| context.name()).collect();
|
||||
problems.push(format!("[{section}] isn't a section: use one of {}", sections.join(", ")));
|
||||
continue;
|
||||
};
|
||||
let Some(entries) = value.as_table() else {
|
||||
problems.push(format!("{section} should be a section, [{section}], with keys in it"));
|
||||
continue;
|
||||
};
|
||||
for (keys_text, name) in entries {
|
||||
if let Err(problem) = keymap.apply(context, keys_text, name) {
|
||||
problems.push(format!("[{section}] {keys_text:?}: {problem}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
problems.extend(keymap.hidden_bindings());
|
||||
(keymap, problems)
|
||||
}
|
||||
|
||||
/// One line of a `keys.toml` section: bind `keys_text` to the command
|
||||
/// named by `name`, or unbind it for "nothing".
|
||||
fn apply(&mut self, context: Context, keys_text: &str, name: &toml::Value) -> Result<(), String> {
|
||||
let keys = parse_keys(keys_text, self.leader).map_err(|error| error.to_string())?;
|
||||
if let Some(first) = keys[0].as_char() {
|
||||
if first.is_ascii_digit() && first != '0' {
|
||||
return Err("keys can't start with 1–9: those type a count".into());
|
||||
}
|
||||
if first == '"' {
|
||||
return Err("keys can't start with \": it names a register".into());
|
||||
}
|
||||
}
|
||||
let Some(name) = name.as_str() else {
|
||||
return Err("the command should be a name in quotes, like \"delete-branch\"".into());
|
||||
};
|
||||
let table = self.tables.entry(context).or_default();
|
||||
table.retain(|binding| binding.keys != keys);
|
||||
if name == "nothing" {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(info) = command::by_name(name) else {
|
||||
return Err(match suggestion(name) {
|
||||
Some(close) => format!("there's no command {name:?}; did you mean {close:?}?"),
|
||||
None => format!("there's no command {name:?}"),
|
||||
});
|
||||
};
|
||||
table.push(Binding { keys, bound: info.bound, help: info.help.to_string() });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bindings that can never be typed because a shorter one starts the
|
||||
/// same way (with `x` bound, `xy` can't be reached), in words.
|
||||
fn hidden_bindings(&self) -> Vec<String> {
|
||||
let mut problems = Vec::new();
|
||||
for (situation, contexts) in SITUATIONS {
|
||||
let bindings = self.tables(contexts).bindings();
|
||||
for short in &bindings {
|
||||
for long in &bindings {
|
||||
if long.keys.len() > short.keys.len() && long.keys.starts_with(&short.keys) {
|
||||
problems.push(format!(
|
||||
"in {situation}, {} hides {}: typing {} runs \"{}\" first",
|
||||
notation(&short.keys),
|
||||
notation(&long.keys),
|
||||
notation(&short.keys),
|
||||
short.help
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
/// The leader key.
|
||||
pub fn leader(&self) -> Key {
|
||||
self.leader
|
||||
}
|
||||
|
||||
/// The tables for some contexts, most specific first (see
|
||||
/// [`SITUATIONS`]).
|
||||
pub fn tables<'a>(&'a self, contexts: &'a [Context]) -> Tables<'a> {
|
||||
Tables { keymap: self, contexts }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Tables<'a> {
|
||||
/// Every binding in effect, in order. Where two tables bind the same
|
||||
/// keys, only the first one's binding is here.
|
||||
pub fn bindings(&self) -> Vec<&'a Binding> {
|
||||
let mut found: Vec<&'a Binding> = Vec::new();
|
||||
for context in self.contexts {
|
||||
for binding in self.keymap.tables.get(context).into_iter().flatten() {
|
||||
if found.iter().all(|other| other.keys != binding.keys) {
|
||||
found.push(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The bindings whose keys start with `keys`.
|
||||
pub fn commands_starting_with(&self, keys: &[Key]) -> Vec<&'a Binding> {
|
||||
self.bindings().into_iter().filter(|binding| binding.keys.starts_with(keys)).collect()
|
||||
}
|
||||
|
||||
/// The targets whose keys start with `keys`.
|
||||
pub fn targets_starting_with(&self, keys: &[Key]) -> Vec<&'a TargetBinding> {
|
||||
self.keymap.targets.iter().filter(|target| target.keys.starts_with(keys)).collect()
|
||||
}
|
||||
|
||||
/// The name of a group of keys, like "go to" for `g`.
|
||||
pub fn group_name(&self, keys: &[Key]) -> Option<&'static str> {
|
||||
self.keymap.groups.iter().find(|(group, _)| group == keys).map(|(_, name)| *name)
|
||||
}
|
||||
|
||||
/// The name of a group of targets, like "around" for `a`.
|
||||
pub fn target_group_name(&self, keys: &[Key]) -> Option<&'static str> {
|
||||
self.keymap.target_groups.iter().find(|(group, _)| group == keys).map(|(_, name)| *name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A command name close to a mistyped one, to suggest it.
|
||||
fn suggestion(name: &str) -> Option<&'static str> {
|
||||
let shared = |other: &str| name.chars().zip(other.chars()).take_while(|(a, b)| a == b).count();
|
||||
COMMANDS
|
||||
.iter()
|
||||
.map(|info| info.name)
|
||||
.filter(|other| other.contains(name) || shared(other) >= 3)
|
||||
.max_by_key(|other| shared(other))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::input::command::{Command, Motion};
|
||||
|
||||
fn keys(text: &str) -> Vec<Key> {
|
||||
parse_keys(text, DEFAULT_LEADER).unwrap()
|
||||
}
|
||||
|
||||
fn bound_to(keymap: &Keymap, contexts: &[Context], text: &str) -> Option<Bound> {
|
||||
let tables = keymap.tables(contexts);
|
||||
tables.bindings().into_iter().find(|binding| binding.keys == keys(text)).map(|binding| binding.bound)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_key_parses_and_none_hides_another() {
|
||||
let keymap = Keymap::new(DEFAULT_LEADER);
|
||||
for context in Context::ALL {
|
||||
assert_eq!(keymap.tables[&context].len(), context.defaults().len(), "{context:?}");
|
||||
}
|
||||
assert_eq!(keymap.targets.len(), NODE_TARGETS.len());
|
||||
assert_eq!(keymap.hidden_bindings(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_toml_changes_and_removes_keys() {
|
||||
let (keymap, problems) = Keymap::from_toml(
|
||||
r#"
|
||||
leader = ","
|
||||
[normal]
|
||||
"Q" = "close"
|
||||
"x" = "nothing"
|
||||
"<leader>h" = "help"
|
||||
"#,
|
||||
);
|
||||
assert_eq!(problems, Vec::<String>::new());
|
||||
let normal = &[Context::Canvas, Context::Normal, Context::Motions];
|
||||
assert_eq!(bound_to(&keymap, normal, "Q"), Some(Bound::Command(Command::Close)));
|
||||
assert_eq!(bound_to(&keymap, normal, "x"), None);
|
||||
assert_eq!(bound_to(&keymap, normal, ",h"), Some(Bound::Command(Command::Help)));
|
||||
assert_eq!(bound_to(&keymap, normal, ",w"), Some(Bound::Command(Command::ToggleWhichKey)));
|
||||
assert_eq!(bound_to(&keymap, normal, "h"), Some(Bound::Command(Command::Move(Motion::Left))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mistakes_are_reported_and_skipped() {
|
||||
let (keymap, problems) = Keymap::from_toml(
|
||||
r#"
|
||||
[normal]
|
||||
"<Spcae>" = "help"
|
||||
"X" = "split-vert"
|
||||
"5x" = "help"
|
||||
"Y" = 3
|
||||
"g" = "help"
|
||||
[nromal]
|
||||
"#,
|
||||
);
|
||||
// TOML hands sections and keys over in alphabetical order.
|
||||
assert_eq!(
|
||||
problems[..5],
|
||||
[
|
||||
"[normal] \"5x\": keys can't start with 1–9: those type a count",
|
||||
"[normal] \"<Spcae>\": there's no key called <Spcae>",
|
||||
"[normal] \"X\": there's no command \"split-vert\"; did you mean \"split-vertical\"?",
|
||||
"[normal] \"Y\": the command should be a name in quotes, like \"delete-branch\"",
|
||||
"[nromal] isn't a section: use one of motions, normal, canvas, outline, note, visual, move, connect, list",
|
||||
]
|
||||
);
|
||||
let hidden = "in normal mode on the canvas, g hides gt: typing g runs \"help: every key\" first";
|
||||
assert!(problems.iter().any(|problem| problem == hidden), "{problems:#?}");
|
||||
assert_eq!(
|
||||
bound_to(&keymap, &[Context::Normal], "u"),
|
||||
Some(Bound::Command(Command::Undo)),
|
||||
"the rest works"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Keys and the mouse: turning what you press into commands.
|
||||
//!
|
||||
//! - [`key`]: one key press, and vim's notation for writing keys (`<C-w>v`).
|
||||
//! - [`command`]: everything a key can ask for, with names for `keys.toml`.
|
||||
//! - [`bindings`]: the default keys, in tables per mode and view.
|
||||
//! - [`keymap`]: those tables with your changes from `keys.toml`.
|
||||
//! - [`engine`]: reads presses the way vim does (counts, registers, `d` +
|
||||
//! `ab`) and says what can follow, for which-key.
|
||||
//! - [`mouse`]: presses, double-clicks, drags and the wheel.
|
||||
|
||||
pub mod bindings;
|
||||
pub mod command;
|
||||
pub mod engine;
|
||||
pub mod key;
|
||||
pub mod keymap;
|
||||
pub mod mouse;
|
||||
@@ -0,0 +1,119 @@
|
||||
//! The mouse: presses, double-clicks, drags and the wheel, read from the
|
||||
//! terminal's raw button events.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||
|
||||
use crate::model::Direction;
|
||||
|
||||
/// Two left presses this close in time, on the same cell, are a double-click.
|
||||
const DOUBLE_CLICK: Duration = Duration::from_millis(400);
|
||||
|
||||
/// A cell on the terminal screen, counted from its top-left corner.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Cell {
|
||||
pub col: u16,
|
||||
pub row: u16,
|
||||
}
|
||||
|
||||
/// What the mouse did, in terms the views care about.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Gesture {
|
||||
/// The left button went down: select what's there. `ctrl`: add to the
|
||||
/// selection instead.
|
||||
Press { at: Cell, ctrl: bool },
|
||||
/// A second left press on the same cell, soon after the first.
|
||||
DoublePress { at: Cell },
|
||||
/// The right button went down: the menu.
|
||||
RightPress { at: Cell },
|
||||
/// The mouse moved with the left button down, since it was pressed at
|
||||
/// `from`.
|
||||
Drag { from: Cell, at: Cell, ctrl: bool },
|
||||
/// The left button came up; `dragged` tells whether it moved first.
|
||||
Release { at: Cell, dragged: bool },
|
||||
/// The wheel turned (sideways on a touchpad).
|
||||
Scroll { at: Cell, direction: Direction },
|
||||
}
|
||||
|
||||
/// Turns raw mouse events into gestures. It remembers the last press, to
|
||||
/// spot double-clicks and drags.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MouseReader {
|
||||
last_press: Option<(Instant, Cell)>,
|
||||
pressed_at: Option<Cell>,
|
||||
dragged: bool,
|
||||
}
|
||||
|
||||
impl MouseReader {
|
||||
/// Reads one event. `now` is when it arrived.
|
||||
pub fn read(&mut self, event: MouseEvent, now: Instant) -> Option<Gesture> {
|
||||
let at = Cell { col: event.column, row: event.row };
|
||||
let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match event.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
let double = self
|
||||
.last_press
|
||||
.is_some_and(|(time, cell)| cell == at && now.duration_since(time) <= DOUBLE_CLICK);
|
||||
self.pressed_at = Some(at);
|
||||
self.dragged = false;
|
||||
if double {
|
||||
// A third press starts over rather than making another double.
|
||||
self.last_press = None;
|
||||
Some(Gesture::DoublePress { at })
|
||||
} else {
|
||||
self.last_press = Some((now, at));
|
||||
Some(Gesture::Press { at, ctrl })
|
||||
}
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Right) => Some(Gesture::RightPress { at }),
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
let from = self.pressed_at?;
|
||||
self.dragged = true;
|
||||
Some(Gesture::Drag { from, at, ctrl })
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
self.pressed_at.take()?;
|
||||
Some(Gesture::Release { at, dragged: std::mem::take(&mut self.dragged) })
|
||||
}
|
||||
MouseEventKind::ScrollUp => Some(Gesture::Scroll { at, direction: Direction::Up }),
|
||||
MouseEventKind::ScrollDown => Some(Gesture::Scroll { at, direction: Direction::Down }),
|
||||
MouseEventKind::ScrollLeft => Some(Gesture::Scroll { at, direction: Direction::Left }),
|
||||
MouseEventKind::ScrollRight => Some(Gesture::Scroll { at, direction: Direction::Right }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn event(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent {
|
||||
MouseEvent { kind, column: col, row, modifiers: KeyModifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presses_doubles_and_drags() {
|
||||
let mut mouse = MouseReader::default();
|
||||
let start = Instant::now();
|
||||
let at = |ms| start + Duration::from_millis(ms);
|
||||
let down = MouseEventKind::Down(MouseButton::Left);
|
||||
let up = MouseEventKind::Up(MouseButton::Left);
|
||||
let cell = Cell { col: 3, row: 4 };
|
||||
|
||||
assert_eq!(mouse.read(event(down, 3, 4), at(0)), Some(Gesture::Press { at: cell, ctrl: false }));
|
||||
assert_eq!(mouse.read(event(up, 3, 4), at(50)), Some(Gesture::Release { at: cell, dragged: false }));
|
||||
assert_eq!(mouse.read(event(down, 3, 4), at(200)), Some(Gesture::DoublePress { at: cell }));
|
||||
mouse.read(event(up, 3, 4), at(250));
|
||||
assert!(matches!(mouse.read(event(down, 3, 4), at(1000)), Some(Gesture::Press { .. })), "too late");
|
||||
|
||||
let drag = MouseEventKind::Drag(MouseButton::Left);
|
||||
let to = Cell { col: 9, row: 4 };
|
||||
assert_eq!(
|
||||
mouse.read(event(drag, 9, 4), at(1100)),
|
||||
Some(Gesture::Drag { from: cell, at: to, ctrl: false })
|
||||
);
|
||||
assert_eq!(mouse.read(event(up, 9, 4), at(1200)), Some(Gesture::Release { at: to, dragged: true }));
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -7,10 +7,12 @@
|
||||
//! | Module | What's in it |
|
||||
//! |-------------|--------------------------------------------------------------|
|
||||
//! | [`model`] | the data and its rules: documents, nodes, links, undo, folds |
|
||||
//! | [`input`] | keys and the mouse: vim keys, which-key, keys.toml |
|
||||
//! | [`storage`] | files: documents (RON) |
|
||||
//! | [`view`] | the views, one module each: so far, the canvas |
|
||||
//! | [`ui`] | what views share: glyphs, the theme |
|
||||
//! | [`ui`] | what views share: glyphs, the theme, the which-key popup |
|
||||
|
||||
pub mod input;
|
||||
pub mod model;
|
||||
pub mod storage;
|
||||
pub mod ui;
|
||||
|
||||
@@ -128,3 +128,25 @@ impl CanvasRect {
|
||||
&& other.top() < self.bottom()
|
||||
}
|
||||
}
|
||||
|
||||
/// The four directions, on screen and on the canvas. Up is toward smaller
|
||||
/// `y`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Direction {
|
||||
Up,
|
||||
Right,
|
||||
Down,
|
||||
Left,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// The direction pointing the other way.
|
||||
pub fn opposite(self) -> Direction {
|
||||
match self {
|
||||
Direction::Up => Direction::Down,
|
||||
Direction::Right => Direction::Left,
|
||||
Direction::Down => Direction::Up,
|
||||
Direction::Left => Direction::Right,
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ mod text;
|
||||
pub use document::{Document, NodeRecord, Place, StructureError};
|
||||
pub use edit::{Change, Edit, EditError, Spot};
|
||||
pub use fold::Visible;
|
||||
pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize};
|
||||
pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction};
|
||||
pub use history::{History, StateId};
|
||||
pub use ids::{DocUid, NodeId};
|
||||
pub use link::{Link, LinkKey};
|
||||
|
||||
+1
-22
@@ -4,7 +4,7 @@
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::model::Stroke;
|
||||
use crate::model::{Direction, Stroke};
|
||||
|
||||
/// How heavy one arm of a line is, in one cell.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -26,27 +26,6 @@ impl Weight {
|
||||
}
|
||||
}
|
||||
|
||||
/// The four directions on screen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Direction {
|
||||
Up,
|
||||
Right,
|
||||
Down,
|
||||
Left,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// The direction pointing the other way.
|
||||
pub fn opposite(self) -> Direction {
|
||||
match self {
|
||||
Direction::Up => Direction::Down,
|
||||
Direction::Right => Direction::Left,
|
||||
Direction::Down => Direction::Up,
|
||||
Direction::Left => Direction::Right,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The arms of the lines that meet in one cell.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Arms {
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
//! What the views share: the characters they draw with ([`glyphs`]) and the
|
||||
//! colors and looks ([`theme`]). The which-key popup, the command line, the
|
||||
//! bars and the other shared widgets come in later stages.
|
||||
//! What the views share: the characters they draw with ([`glyphs`]), the
|
||||
//! colors and looks ([`theme`]) and the which-key popup ([`which_key`]). The
|
||||
//! command line, the bars and the other shared widgets come in later stages.
|
||||
|
||||
pub mod glyphs;
|
||||
pub mod theme;
|
||||
pub mod which_key;
|
||||
@@ -0,0 +1,98 @@
|
||||
//! The which-key popup: after the first key of a sequence (`g`, `z`, the
|
||||
//! leader…), it lists the keys that can follow, the way skyshift does.
|
||||
//!
|
||||
//! ```text
|
||||
//! ╭ g · go to ───────────────────────╮
|
||||
//! │ h the parent g the root │
|
||||
//! │ l the first child f through … │
|
||||
//! ╰──────────────────────────────────╯
|
||||
//! ```
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Widget};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::theme::Theme;
|
||||
use crate::input::engine::Hints;
|
||||
|
||||
/// The popup, for some hints.
|
||||
pub struct WhichKey<'a> {
|
||||
pub hints: &'a Hints,
|
||||
pub theme: &'a Theme,
|
||||
}
|
||||
|
||||
impl Widget for WhichKey<'_> {
|
||||
/// Draws in the bottom-right corner of `area`, two columns and a row in
|
||||
/// from its edges. Long lists spread over several columns.
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let hints = &self.hints.entries;
|
||||
if hints.is_empty() || area.width < 8 || area.height < 3 {
|
||||
return;
|
||||
}
|
||||
let key_width = hints.iter().map(|hint| hint.keys.width()).max().unwrap_or(1);
|
||||
let help_width = hints.iter().map(|hint| hint.help.width()).max().unwrap_or(1);
|
||||
// " key help " plus a space between columns.
|
||||
let entry_width = key_width + help_width + 3;
|
||||
let max_rows = usize::from(area.height.saturating_sub(4)).max(1);
|
||||
let columns = hints.len().div_ceil(max_rows).max(1);
|
||||
let rows = hints.len().div_ceil(columns);
|
||||
let width = u16::try_from(entry_width * columns + 2).unwrap_or(u16::MAX).min(area.width);
|
||||
let height = u16::try_from(rows + 2).unwrap_or(u16::MAX).min(area.height);
|
||||
let rect = Rect {
|
||||
x: area.right().saturating_sub(width + 2).max(area.x),
|
||||
y: area.bottom().saturating_sub(height + 1).max(area.y),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
let key_style = Style::new().fg(self.theme.accent).add_modifier(Modifier::BOLD);
|
||||
let lines: Vec<Line> = (0..rows)
|
||||
.map(|row| {
|
||||
let mut spans = Vec::new();
|
||||
for column in 0..columns {
|
||||
let Some(hint) = hints.get(column * rows + row) else { continue };
|
||||
spans.push(Span::styled(format!(" {:>key_width$} ", hint.keys), key_style));
|
||||
let color = if hint.group { self.theme.accent } else { Color::Reset };
|
||||
spans.push(Span::styled(format!("{:<help_width$} ", hint.help), Style::new().fg(color)));
|
||||
}
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Clear.render(rect, buf);
|
||||
let title = Line::styled(format!(" {} ", self.hints.title), key_style);
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::new().fg(self.theme.muted))
|
||||
.title(title);
|
||||
Paragraph::new(lines).block(block).render(rect, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::input::engine::Hint;
|
||||
|
||||
#[test]
|
||||
fn the_popup_sits_bottom_right_with_its_title() {
|
||||
let hints = Hints {
|
||||
title: "g · go to".into(),
|
||||
entries: vec![
|
||||
Hint { keys: "h".into(), help: "the parent".into(), group: false },
|
||||
Hint { keys: "g".into(), help: "the root".into(), group: false },
|
||||
],
|
||||
};
|
||||
let area = Rect::new(0, 0, 30, 8);
|
||||
let mut buf = Buffer::empty(area);
|
||||
WhichKey { hints: &hints, theme: &Theme::default() }.render(area, &mut buf);
|
||||
let row = |y: u16| (0..30).map(|x| buf[(x, y)].symbol().to_string()).collect::<String>();
|
||||
assert_eq!(row(3), " ╭ g · go to ───╮ ");
|
||||
assert_eq!(row(4), " │ h the parent │ ");
|
||||
assert_eq!(row(5), " │ g the root │ ");
|
||||
assert_eq!(row(6), " ╰──────────────╯ ");
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ use ratatui::layout::Size;
|
||||
use ratatui::style::Color;
|
||||
|
||||
use super::camera::{ScreenPos, ScreenRect};
|
||||
use crate::model::Direction;
|
||||
use crate::model::{Border, LinkKey, NodeId, Stroke};
|
||||
use crate::ui::glyphs::{Arms, Dash, Direction, Glyphs, Weight};
|
||||
use crate::ui::glyphs::{Arms, Dash, Glyphs, Weight};
|
||||
|
||||
/// What a line cell belongs to, so a click on a line knows what it hit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Moving between nodes with hjkl: the nearest node in a direction.
|
||||
|
||||
use crate::model::Direction;
|
||||
use crate::model::{CanvasRect, NodeId};
|
||||
use crate::ui::glyphs::Direction;
|
||||
|
||||
/// The node to go to from `from`, heading `direction`: the nearest one whose
|
||||
/// middle lies that way. Nodes within 45° of straight ahead come first, and
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use super::camera::{ScreenPos, ScreenRect};
|
||||
use super::lines::{direction_between, step};
|
||||
use crate::ui::glyphs::Direction;
|
||||
use crate::model::Direction;
|
||||
|
||||
/// Where a line that has to change rows (or columns) bends.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
Reference in new issue
Block a user