Skip to main content

caretline/
commands.rs

1//! The editing vocabulary: every generic editing command with a stable id, a name, a short
2//! description and a category, each one message ([`command_msg`]), and the default keymap as
3//! data ([`default_keymap`]): key chord → command id.
4//!
5//! caretline owns the vocabulary and a default; an app owns its final bindings. A host takes the
6//! table, overrides entries or ignores it, adds its own commands (registered with
7//! [`crate::Host::command`]) under its own ids, and builds its help from [`commands`], so help
8//! never drifts from what the keys do. [`crate::keymap()`] is a lookup in this table.
9//!
10//! Ids are `category.verb` and never change within a major version.
11
12use std::collections::HashMap;
13use std::sync::OnceLock;
14
15use serde::Serialize;
16
17use crate::keymap::{parse_keys, Key, KeyCode, Mods, ScriptItem};
18use crate::marks::MarkId;
19use crate::msg::{By, Dir, Msg};
20
21/// What a command is about.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Category {
25    Move,
26    Select,
27    Edit,
28    Clipboard,
29    History,
30    Structure,
31    View,
32    File,
33}
34
35impl Category {
36    pub fn name(self) -> &'static str {
37        match self {
38            Category::Move => "Move",
39            Category::Select => "Select",
40            Category::Edit => "Edit",
41            Category::Clipboard => "Clipboard",
42            Category::History => "History",
43            Category::Structure => "Structure",
44            Category::View => "View",
45            Category::File => "File",
46        }
47    }
48}
49
50/// One editing command.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
52pub struct CommandInfo {
53    /// Stable: `move.word_right`, `select.all`, `history.undo`.
54    pub id: &'static str,
55    /// For menus and help: `Word right`.
56    pub name: &'static str,
57    pub description: &'static str,
58    pub category: Category,
59    /// Meaningful only in an outline document (elsewhere it says so, or acts on lines).
60    #[serde(skip_serializing_if = "std::ops::Not::not")]
61    pub outline_only: bool,
62    /// Acts on one block, which the host names ([`command_msg`]'s `block`).
63    #[serde(skip_serializing_if = "std::ops::Not::not")]
64    pub takes_block: bool,
65}
66
67/// Which keyboards a binding is for.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
69#[serde(rename_all = "snake_case")]
70pub enum Platform {
71    /// Every keyboard and terminal.
72    Any,
73    /// A Command (⌘) chord: macOS, in a terminal that forwards ⌘ (the kitty keyboard
74    /// protocol's "super"). Each has a Ctrl or Alt twin for terminals that don't.
75    Mac,
76}
77
78/// One default binding: a key chord in key-script notation (`<c-s-z>`, `<a-left>`, `<d-a>`,
79/// see [`parse_keys`]) and the command it runs.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
81pub struct Binding {
82    pub keys: &'static str,
83    pub command: &'static str,
84    pub platform: Platform,
85    /// Only in an outline document's keymap (on top of the plain one).
86    #[serde(skip_serializing_if = "std::ops::Not::not")]
87    pub outline: bool,
88}
89
90macro_rules! cmd {
91    ($id:literal, $name:literal, $cat:ident, $desc:literal) => {
92        CommandInfo { id: $id, name: $name, description: $desc, category: Category::$cat, outline_only: false, takes_block: false }
93    };
94    ($id:literal, $name:literal, $cat:ident, $desc:literal, outline) => {
95        CommandInfo { id: $id, name: $name, description: $desc, category: Category::$cat, outline_only: true, takes_block: false }
96    };
97    ($id:literal, $name:literal, $cat:ident, $desc:literal, block) => {
98        CommandInfo { id: $id, name: $name, description: $desc, category: Category::$cat, outline_only: true, takes_block: true }
99    };
100}
101
102const COMMANDS: &[CommandInfo] = &[
103    cmd!("move.left", "Left", Move, "Move left one character"),
104    cmd!("move.right", "Right", Move, "Move right one character"),
105    cmd!("move.up", "Up", Move, "Move up one row"),
106    cmd!("move.down", "Down", Move, "Move down one row"),
107    cmd!("move.word_left", "Word left", Move, "Move to the start of the previous word"),
108    cmd!("move.word_right", "Word right", Move, "Move to the end of the next word"),
109    cmd!("move.line_start", "Line start", Move, "Move to the start of the row"),
110    cmd!("move.line_end", "Line end", Move, "Move to the end of the row"),
111    cmd!("move.doc_start", "Document start", Move, "Move to the start of the document"),
112    cmd!("move.doc_end", "Document end", Move, "Move to the end of the document"),
113    cmd!("move.page_up", "Page up", Move, "Move up a screenful"),
114    cmd!("move.page_down", "Page down", Move, "Move down a screenful"),
115    cmd!("move.block_up", "Block up", Move, "Move to the start of this block, then the one before"),
116    cmd!("move.block_down", "Block down", Move, "Move to the start of the next block"),
117    cmd!("select.left", "Select left", Select, "Extend the selection left one character"),
118    cmd!("select.right", "Select right", Select, "Extend the selection right one character"),
119    cmd!("select.up", "Select up", Select, "Extend the selection up one row"),
120    cmd!("select.down", "Select down", Select, "Extend the selection down one row"),
121    cmd!("select.word_left", "Select word left", Select, "Extend the selection to the previous word start"),
122    cmd!("select.word_right", "Select word right", Select, "Extend the selection to the next word end"),
123    cmd!("select.line_start", "Select to line start", Select, "Extend the selection to the start of the row"),
124    cmd!("select.line_end", "Select to line end", Select, "Extend the selection to the end of the row"),
125    cmd!("select.doc_start", "Select to document start", Select, "Extend the selection to the start of the document"),
126    cmd!("select.doc_end", "Select to document end", Select, "Extend the selection to the end of the document"),
127    cmd!("select.page_up", "Select page up", Select, "Extend the selection up a screenful"),
128    cmd!("select.page_down", "Select page down", Select, "Extend the selection down a screenful"),
129    cmd!("select.block_up", "Select block up", Select, "Extend the selection to the previous block start"),
130    cmd!("select.block_down", "Select block down", Select, "Extend the selection to the next block start"),
131    cmd!("select.all", "Select all", Select, "Select the whole document"),
132    cmd!("select.collapse", "Collapse selection", Select, "Collapse every selection to its caret"),
133    cmd!("select.block", "Select block", Select, "Select a block's whole content", block),
134    cmd!("edit.newline", "New line", Edit, "Break the line (in a list, start the next item)"),
135    cmd!("edit.soft_break", "Line break", Edit, "Break the line inside the block", outline),
136    cmd!("edit.insert_tab", "Tab", Edit, "Insert a tab character"),
137    cmd!("edit.backspace", "Backspace", Edit, "Delete the character before the caret, or the selection"),
138    cmd!("edit.delete_forward", "Delete", Edit, "Delete the character after the caret, or the selection"),
139    cmd!("edit.delete_word", "Delete word", Edit, "Delete back to the previous word start"),
140    cmd!("edit.delete_word_forward", "Delete word forward", Edit, "Delete forward to the next word end"),
141    cmd!("edit.delete_to_line_start", "Delete to line start", Edit, "Delete back to the start of the row"),
142    cmd!("edit.delete_to_line_end", "Delete to line end", Edit, "Delete forward to the end of the row"),
143    cmd!("edit.kill_line", "Kill line", Edit, "Delete to the end of the line, or the line break when there"),
144    cmd!("structure.indent", "Indent", Structure, "Nest the block (or every selected block) one level deeper", outline),
145    cmd!("structure.outdent", "Outdent", Structure, "Un-nest the block (or every selected block) one level", outline),
146    cmd!("structure.move_up", "Move block up", Structure, "Swap the block and its children with the sibling above", outline),
147    cmd!("structure.move_down", "Move block down", Structure, "Swap the block and its children with the sibling below", outline),
148    cmd!("clip.copy", "Copy", Clipboard, "Copy the selection"),
149    cmd!("clip.cut", "Cut", Clipboard, "Cut the selection"),
150    cmd!("clip.paste", "Paste", Clipboard, "Paste (Markdown becomes blocks in an outline)"),
151    cmd!("clip.paste_plain", "Paste as plain text", Clipboard, "Paste as plain paragraphs, never list items"),
152    cmd!("history.undo", "Undo", History, "Undo the last step"),
153    cmd!("history.redo", "Redo", History, "Redo the step undone"),
154    cmd!("view.fold", "Fold", View, "Hide a block's children in this view", block),
155    cmd!("view.unfold", "Unfold", View, "Show a folded block's children", block),
156    cmd!("view.fold_toggle", "Toggle fold", View, "Fold or unfold a block", block),
157    cmd!("file.save", "Save", File, "Write the document to its file"),
158    cmd!("file.quit", "Quit", File, "Quit (a second time to discard unsaved changes)"),
159];
160
161/// Every editing command, in display order.
162pub fn commands() -> &'static [CommandInfo] {
163    COMMANDS
164}
165
166/// The command with this id.
167pub fn command(id: &str) -> Option<&'static CommandInfo> {
168    COMMANDS.iter().find(|c| c.id == id)
169}
170
171/// The message a command sends. `block` names the block for the commands that take one (none
172/// without it).
173pub fn command_msg(id: &str, block: Option<MarkId>) -> Option<Msg> {
174    use Dir::{Backward as B, Forward as F};
175    let mv = |dir, by, extend| Some(Msg::Move { dir, by, extend });
176    let (verb, extend) = match id.split_once('.') {
177        Some(("move", v)) => (v, false),
178        Some(("select", v)) if !matches!(v, "all" | "collapse" | "block") => (v, true),
179        _ => ("", false),
180    };
181    let motion = match verb {
182        "left" => mv(B, By::Grapheme, extend),
183        "right" => mv(F, By::Grapheme, extend),
184        "up" => mv(B, By::VisualLine, extend),
185        "down" => mv(F, By::VisualLine, extend),
186        "word_left" => mv(B, By::Word, extend),
187        "word_right" => mv(F, By::Word, extend),
188        "line_start" => mv(B, By::LineStart, extend),
189        "line_end" => mv(F, By::LineEnd, extend),
190        "doc_start" => mv(B, By::DocStart, extend),
191        "doc_end" => mv(F, By::DocEnd, extend),
192        "page_up" => mv(B, By::Page, extend),
193        "page_down" => mv(F, By::Page, extend),
194        "block_up" => mv(B, By::Block, extend),
195        "block_down" => mv(F, By::Block, extend),
196        _ => None,
197    };
198    if motion.is_some() {
199        return motion;
200    }
201    Some(match id {
202        "select.all" => Msg::SelectAll,
203        "select.collapse" => Msg::Collapse,
204        "select.block" => Msg::SelectBlock { id: block? },
205        "edit.newline" => Msg::InsertNewline,
206        "edit.soft_break" => Msg::SoftBreak,
207        "edit.insert_tab" => Msg::InsertText { text: "\t".into() },
208        "edit.backspace" => Msg::DeleteBackward,
209        "edit.delete_forward" => Msg::DeleteForward,
210        "edit.delete_word" => Msg::DeleteWordBackward,
211        "edit.delete_word_forward" => Msg::DeleteWordForward,
212        "edit.delete_to_line_start" => Msg::DeleteToLineStart,
213        "edit.delete_to_line_end" => Msg::DeleteToLineEnd,
214        "edit.kill_line" => Msg::KillLine,
215        "structure.indent" => Msg::Indent,
216        "structure.outdent" => Msg::Outdent,
217        "structure.move_up" => Msg::MoveBlock { dir: B },
218        "structure.move_down" => Msg::MoveBlock { dir: F },
219        "clip.copy" => Msg::Copy,
220        "clip.cut" => Msg::Cut,
221        "clip.paste" => Msg::Paste { text: None },
222        "clip.paste_plain" => Msg::PastePlain { text: None },
223        "history.undo" => Msg::Undo,
224        "history.redo" => Msg::Redo,
225        "view.fold" => Msg::Fold { id: block? },
226        "view.unfold" => Msg::Unfold { id: block? },
227        "view.fold_toggle" => Msg::ToggleFold { id: block? },
228        "file.save" => Msg::Save,
229        "file.quit" => Msg::Quit,
230        _ => return None,
231    })
232}
233
234macro_rules! bind {
235    ($keys:literal, $cmd:literal) => {
236        Binding { keys: $keys, command: $cmd, platform: Platform::Any, outline: false }
237    };
238    ($keys:literal, $cmd:literal, mac) => {
239        Binding { keys: $keys, command: $cmd, platform: Platform::Mac, outline: false }
240    };
241    ($keys:literal, $cmd:literal, outline) => {
242        Binding { keys: $keys, command: $cmd, platform: Platform::Any, outline: true }
243    };
244}
245
246/// The default keymap: macOS text-field keys first, with Ctrl and Alt twins for terminals
247/// that don't forward ⌘. Printable keys without Ctrl, Alt or ⌘ type themselves.
248const BINDINGS: &[Binding] = &[
249    bind!("<left>", "move.left"),
250    bind!("<right>", "move.right"),
251    bind!("<up>", "move.up"),
252    bind!("<down>", "move.down"),
253    bind!("<a-left>", "move.word_left"),
254    bind!("<c-left>", "move.word_left"),
255    bind!("<a-b>", "move.word_left"),
256    bind!("<a-right>", "move.word_right"),
257    bind!("<c-right>", "move.word_right"),
258    bind!("<a-f>", "move.word_right"),
259    bind!("<d-left>", "move.line_start", mac),
260    bind!("<home>", "move.line_start"),
261    bind!("<c-a>", "move.line_start"),
262    bind!("<d-right>", "move.line_end", mac),
263    bind!("<end>", "move.line_end"),
264    bind!("<c-e>", "move.line_end"),
265    bind!("<d-up>", "move.doc_start", mac),
266    bind!("<c-home>", "move.doc_start"),
267    bind!("<d-home>", "move.doc_start", mac),
268    bind!("<d-down>", "move.doc_end", mac),
269    bind!("<c-end>", "move.doc_end"),
270    bind!("<d-end>", "move.doc_end", mac),
271    bind!("<pgup>", "move.page_up"),
272    bind!("<pgdn>", "move.page_down"),
273    bind!("<c-up>", "move.block_up", outline),
274    bind!("<c-down>", "move.block_down", outline),
275    bind!("<s-left>", "select.left"),
276    bind!("<s-right>", "select.right"),
277    bind!("<s-up>", "select.up"),
278    bind!("<s-down>", "select.down"),
279    bind!("<a-s-left>", "select.word_left"),
280    bind!("<c-s-left>", "select.word_left"),
281    bind!("<a-s-b>", "select.word_left"),
282    bind!("<a-s-right>", "select.word_right"),
283    bind!("<c-s-right>", "select.word_right"),
284    bind!("<a-s-f>", "select.word_right"),
285    bind!("<d-s-left>", "select.line_start", mac),
286    bind!("<s-home>", "select.line_start"),
287    bind!("<c-s-a>", "select.line_start"),
288    bind!("<d-s-right>", "select.line_end", mac),
289    bind!("<s-end>", "select.line_end"),
290    bind!("<c-s-e>", "select.line_end"),
291    bind!("<d-s-up>", "select.doc_start", mac),
292    bind!("<c-s-home>", "select.doc_start"),
293    bind!("<d-s-home>", "select.doc_start", mac),
294    bind!("<d-s-down>", "select.doc_end", mac),
295    bind!("<c-s-end>", "select.doc_end"),
296    bind!("<d-s-end>", "select.doc_end", mac),
297    bind!("<s-pgup>", "select.page_up"),
298    bind!("<s-pgdn>", "select.page_down"),
299    bind!("<c-s-up>", "select.block_up", outline),
300    bind!("<c-s-down>", "select.block_down", outline),
301    bind!("<d-a>", "select.all", mac),
302    bind!("<a-a>", "select.all"),
303    bind!("<esc>", "select.collapse"),
304    bind!("<s-esc>", "select.collapse"),
305    bind!("<cr>", "edit.newline"),
306    bind!("<s-cr>", "edit.newline"),
307    bind!("<s-cr>", "edit.soft_break", outline),
308    bind!("<c-j>", "edit.soft_break", outline),
309    bind!("<tab>", "edit.insert_tab"),
310    bind!("<bs>", "edit.backspace"),
311    bind!("<s-bs>", "edit.backspace"),
312    bind!("<c-h>", "edit.backspace"),
313    bind!("<del>", "edit.delete_forward"),
314    bind!("<s-del>", "edit.delete_forward"),
315    bind!("<a-bs>", "edit.delete_word"),
316    bind!("<c-bs>", "edit.delete_word"),
317    bind!("<c-w>", "edit.delete_word"),
318    bind!("<a-del>", "edit.delete_word_forward"),
319    bind!("<c-del>", "edit.delete_word_forward"),
320    bind!("<a-d>", "edit.delete_word_forward"),
321    bind!("<d-bs>", "edit.delete_to_line_start", mac),
322    bind!("<c-u>", "edit.delete_to_line_start"),
323    bind!("<d-del>", "edit.delete_to_line_end", mac),
324    bind!("<c-k>", "edit.kill_line"),
325    bind!("<tab>", "structure.indent", outline),
326    bind!("<s-tab>", "structure.outdent", outline),
327    bind!("<a-up>", "structure.move_up", outline),
328    bind!("<a-down>", "structure.move_down", outline),
329    bind!("<d-c>", "clip.copy", mac),
330    bind!("<c-c>", "clip.copy"),
331    bind!("<d-x>", "clip.cut", mac),
332    bind!("<c-x>", "clip.cut"),
333    bind!("<d-v>", "clip.paste", mac),
334    bind!("<c-v>", "clip.paste"),
335    bind!("<a-v>", "clip.paste_plain", outline),
336    bind!("<d-z>", "history.undo", mac),
337    bind!("<c-z>", "history.undo"),
338    bind!("<d-s-z>", "history.redo", mac),
339    bind!("<c-s-z>", "history.redo"),
340    bind!("<d-y>", "history.redo", mac),
341    bind!("<c-y>", "history.redo"),
342    bind!("<d-r>", "history.redo", mac),
343    bind!("<c-r>", "history.redo"),
344    bind!("<d-s>", "file.save", mac),
345    bind!("<c-s>", "file.save"),
346    bind!("<d-q>", "file.quit", mac),
347    bind!("<c-q>", "file.quit"),
348];
349
350/// The default bindings: the plain keymap, and with `outline` an outline document's (its
351/// bindings replace a plain one on the same keys).
352pub fn default_keymap(outline: bool) -> Vec<Binding> {
353    let outline_keys: Vec<&str> = BINDINGS.iter().filter(|b| b.outline).map(|b| b.keys).collect();
354    BINDINGS
355        .iter()
356        .filter(|b| if outline { b.outline || !outline_keys.contains(&b.keys) } else { !b.outline })
357        .copied()
358        .collect()
359}
360
361/// A key in the table's notation: modifiers in the order `c-`, `a-`, `d-`, `s-`, then the key.
362/// Letters are lowercase (⇧ is the `s-`).
363pub fn key_notation(key: &Key) -> String {
364    let mut mods = key.mods;
365    let name = match key.code {
366        KeyCode::Char(c) => {
367            if c.is_ascii_uppercase() {
368                mods.shift = true;
369            }
370            match c.to_ascii_lowercase() {
371                ' ' => "space".to_string(),
372                '<' => "lt".to_string(),
373                '>' => "gt".to_string(),
374                c => c.to_string(),
375            }
376        }
377        KeyCode::Enter => "cr".into(),
378        KeyCode::Backspace => "bs".into(),
379        KeyCode::Delete => "del".into(),
380        KeyCode::Left => "left".into(),
381        KeyCode::Right => "right".into(),
382        KeyCode::Up => "up".into(),
383        KeyCode::Down => "down".into(),
384        KeyCode::Home => "home".into(),
385        KeyCode::End => "end".into(),
386        KeyCode::PageUp => "pgup".into(),
387        KeyCode::PageDown => "pgdn".into(),
388        KeyCode::Tab => "tab".into(),
389        KeyCode::BackTab => {
390            mods.shift = true;
391            "tab".into()
392        }
393        KeyCode::Esc => "esc".into(),
394    };
395    let mut s = String::from("<");
396    for (on, p) in [(mods.ctrl, "c-"), (mods.alt, "a-"), (mods.cmd, "d-"), (mods.shift, "s-")] {
397        if on {
398            s.push_str(p);
399        }
400    }
401    s.push_str(&name);
402    s.push('>');
403    s
404}
405
406/// The key a binding's chord names.
407pub fn binding_key(b: &Binding) -> Option<Key> {
408    match parse_keys(b.keys).ok()?.as_slice() {
409        [ScriptItem::Key(k)] => Some(*k),
410        _ => None,
411    }
412}
413
414fn table(outline: bool) -> &'static HashMap<String, &'static str> {
415    static PLAIN: OnceLock<HashMap<String, &'static str>> = OnceLock::new();
416    static OUTLINE: OnceLock<HashMap<String, &'static str>> = OnceLock::new();
417    let cell = if outline { &OUTLINE } else { &PLAIN };
418    cell.get_or_init(|| {
419        default_keymap(outline)
420            .into_iter()
421            .filter_map(|b| {
422                let key = binding_key(&b)?;
423                let cmd = COMMANDS.iter().find(|c| c.id == b.command)?.id;
424                Some((key_notation(&key), cmd))
425            })
426            .collect()
427    })
428}
429
430/// The command a key runs in the default keymap, if any.
431pub fn command_for(outline: bool, key: &Key) -> Option<&'static str> {
432    table(outline).get(&key_notation(key)).copied()
433}
434
435/// [`crate::keymap()`]'s lookup: the table, else a printable key without Ctrl, Alt or ⌘ types
436/// itself.
437pub(crate) fn lookup(outline: bool, key: &Key) -> Option<Msg> {
438    if let Some(id) = command_for(outline, key) {
439        return command_msg(id, None);
440    }
441    match key.code {
442        KeyCode::Char(c) if !(key.mods.ctrl || key.mods.alt || key.mods.cmd) => {
443            let c = if key.mods.shift { c.to_uppercase().next().unwrap_or(c) } else { c };
444            Some(Msg::InsertText { text: c.to_string() })
445        }
446        _ => None,
447    }
448}
449
450impl Mods {
451    /// No modifier.
452    pub fn none() -> Mods {
453        Mods::default()
454    }
455}