pub struct Binding {
pub keys: &'static str,
pub desc: &'static str,
pub section: &'static str,
pub live: bool,
}
pub const SECTIONS: &[&str] = &["normal", "visual", "insert", "leader", "git", "ex+panes"];
pub const BINDINGS: &[Binding] = &[
Binding {
keys: "h j k l",
desc: "move (never off the line)",
section: "normal",
live: true,
},
Binding {
keys: "w b e W B E",
desc: "word / WORD motions",
section: "normal",
live: true,
},
Binding {
keys: "0 $ G %",
desc: "line/file/pair jumps",
section: "normal",
live: true,
},
Binding {
keys: "gg",
desc: "top of file",
section: "normal",
live: true,
},
Binding {
keys: "gd",
desc: "goto definition (LSP)",
section: "normal",
live: true,
},
Binding {
keys: "f<c> F<c> t<c> T<c>",
desc: "find/till char (candidates light up)",
section: "normal",
live: true,
},
Binding {
keys: "/ ?",
desc: "search forward / backward",
section: "normal",
live: true,
},
Binding {
keys: "n N",
desc: "next / prev match",
section: "normal",
live: true,
},
Binding {
keys: "]c [c",
desc: "next / prev git hunk",
section: "normal",
live: true,
},
Binding {
keys: "m<a>",
desc: "set mark at cursor",
section: "normal",
live: true,
},
Binding {
keys: "'a `a",
desc: "jump to mark",
section: "normal",
live: true,
},
Binding {
keys: "* # ; ,",
desc: "word under cursor / repeat find",
section: "normal",
live: true,
},
Binding {
keys: "|",
desc: "pipe line/selection through shell (:! runs)",
section: "normal",
live: true,
},
Binding {
keys: "Q",
desc: "toggle cursor at point (multicursor)",
section: "normal",
live: true,
},
Binding {
keys: "d y c > <",
desc: "operators + motion/object (live preview)",
section: "normal",
live: true,
},
Binding {
keys: "dd yy cc D C Y s x X",
desc: "line/char shortcuts",
section: "normal",
live: true,
},
Binding {
keys: "iw i\" i' i( i[ i{",
desc: "inner objects (quotes scan the line)",
section: "normal",
live: true,
},
Binding {
keys: "ds\" cs\"' ysiw\"",
desc: "surround: delete / change / add",
section: "normal",
live: true,
},
Binding {
keys: "i a A o O I",
desc: "insert (auto-indent)",
section: "normal",
live: true,
},
Binding {
keys: "p P",
desc: "paste after / before",
section: "normal",
live: true,
},
Binding {
keys: "r<c> J .",
desc: "replace char / join / repeat",
section: "normal",
live: true,
},
Binding {
keys: "^ ~ S",
desc: "first non-blank / toggle case / change line",
section: "normal",
live: true,
},
Binding {
keys: "u ctrl-r",
desc: "undo / redo (one unit per command)",
section: "normal",
live: true,
},
Binding {
keys: "\"+y \"+p \"+P",
desc: "system clipboard: yank / paste after / before",
section: "normal",
live: true,
},
Binding {
keys: "\"xy \"xp",
desc: "named register: yank / paste",
section: "normal",
live: true,
},
Binding {
keys: "v V",
desc: "visual / visual-line",
section: "normal",
live: true,
},
Binding {
keys: "d y c x > <",
desc: "operate on selection",
section: "visual",
live: true,
},
Binding {
keys: "S<c>",
desc: "wrap selection in pair",
section: "visual",
live: true,
},
Binding {
keys: "i<a> a<a>",
desc: "objects select (vi[ works)",
section: "visual",
live: true,
},
Binding {
keys: "space y",
desc: "yank selection → clipboard",
section: "visual",
live: true,
},
Binding {
keys: "esc",
desc: "normal mode (session = one undo unit)",
section: "insert",
live: true,
},
Binding {
keys: "backspace",
desc: "delete back",
section: "insert",
live: true,
},
Binding {
keys: "enter",
desc: "new line (auto-indent)",
section: "insert",
live: true,
},
Binding {
keys: "} ] )",
desc: "closer on indent-only line dedents",
section: "insert",
live: true,
},
Binding {
keys: "space f",
desc: "file finder",
section: "leader",
live: true,
},
Binding {
keys: "space b",
desc: "buffers (MRU)",
section: "leader",
live: true,
},
Binding {
keys: "space /",
desc: "live grep",
section: "leader",
live: true,
},
Binding {
keys: "space R",
desc: "global search & replace",
section: "leader",
live: true,
},
Binding {
keys: "space ?",
desc: "this popup",
section: "leader",
live: true,
},
Binding {
keys: "space y",
desc: "yank motion → system clipboard",
section: "leader",
live: true,
},
Binding {
keys: "space p",
desc: "paste clipboard after",
section: "leader",
live: true,
},
Binding {
keys: "space P",
desc: "paste clipboard before",
section: "leader",
live: true,
},
Binding {
keys: "space d",
desc: "diagnostics picker",
section: "leader",
live: true,
},
Binding {
keys: "space k",
desc: "hover docs",
section: "leader",
live: true,
},
Binding {
keys: "space j",
desc: "jumplist picker",
section: "leader",
live: false,
},
Binding {
keys: "space u",
desc: "undo-tree browser",
section: "leader",
live: true,
},
Binding {
keys: "space c",
desc: "cursor on next line too (multicursor)",
section: "leader",
live: true,
},
Binding {
keys: "space g",
desc: "git…",
section: "git",
live: true,
},
Binding {
keys: "space g l",
desc: "commit browser",
section: "git",
live: true,
},
Binding {
keys: "space g h",
desc: "file history",
section: "git",
live: true,
},
Binding {
keys: "space g b",
desc: "toggle blame gutter / card",
section: "git",
live: true,
},
Binding {
keys: "space g y",
desc: "permalink: copy",
section: "git",
live: true,
},
Binding {
keys: "space g o",
desc: "permalink: open",
section: "git",
live: true,
},
Binding {
keys: "space g u",
desc: "hunk: undo (reset to HEAD)",
section: "git",
live: true,
},
Binding {
keys: "space g s",
desc: "hunk: stage",
section: "git",
live: true,
},
Binding {
keys: "space g p",
desc: "hunk: preview",
section: "git",
live: true,
},
Binding {
keys: "]f [f",
desc: "next / prev file in commit diff",
section: "git",
live: true,
},
Binding {
keys: "enter",
desc: "dive into the line's commit (blame gutter)",
section: "git",
live: true,
},
Binding {
keys: "q",
desc: "close surface (readonly buffers)",
section: "git",
live: true,
},
Binding {
keys: ":w :q :q! :wq",
desc: "write / quit (force) / write-quit",
section: "ex+panes",
live: true,
},
Binding {
keys: ":e",
desc: "edit file",
section: "ex+panes",
live: true,
},
Binding {
keys: ":help",
desc: "help buffer (this text — / searches it)",
section: "ex+panes",
live: true,
},
Binding {
keys: ":vs :sp",
desc: "split vertical / horizontal",
section: "ex+panes",
live: true,
},
Binding {
keys: "ctrl-w h / l / j / k / w",
desc: "pane move / cycle",
section: "ex+panes",
live: true,
},
Binding {
keys: "ctrl-w v / s",
desc: "pane split (vs / sp)",
section: "ex+panes",
live: true,
},
Binding {
keys: "ctrl-w q",
desc: "close pane (last → buffer)",
section: "ex+panes",
live: true,
},
Binding {
keys: "up down tab s-tab",
desc: "picker navigation",
section: "ex+panes",
live: true,
},
Binding {
keys: "ctrl-x",
desc: "replace picker: exclude/include match",
section: "ex+panes",
live: true,
},
];
pub(crate) fn expand(keys: &str) -> Vec<Vec<&str>> {
let toks: Vec<&str> = keys.split(' ').filter(|t| !t.is_empty()).collect();
let mut seqs: Vec<Vec<&str>> = Vec::new();
let mut i = 0;
while i < toks.len() {
match toks[i] {
"/" if seqs.is_empty() => seqs.push(vec!["/"]),
"/" => {
let base: Vec<&str> = seqs
.last()
.map(|s| s[..s.len() - 1].to_vec())
.unwrap_or_default();
for alt in &toks[i + 1..] {
if *alt != "/" {
let mut seq = base.clone();
seq.push(alt);
seqs.push(seq);
}
}
break;
}
"space" => {
let end = (i + 1..toks.len())
.find(|&j| toks[j] == "/" && j + 1 < toks.len())
.unwrap_or(toks.len());
seqs.push(toks[i..end].to_vec());
i = end;
continue;
}
"ctrl-w" => {
if let Some(k) = toks.get(i + 1) {
seqs.push(vec!["ctrl-w", k]);
i += 2;
} else {
seqs.push(vec!["ctrl-w"]);
i += 1;
}
continue;
}
t => seqs.push(vec![t]),
}
i += 1;
}
seqs
}
pub struct Hint {
pub key: String,
pub desc: &'static str,
pub live: bool,
}
pub fn children_of(prefix: &str, mode: crate::editor::Mode) -> Vec<Hint> {
use crate::editor::Mode;
let sections: &[&str] = match mode {
Mode::Normal => &["normal", "leader", "git", "ex+panes"],
Mode::Visual | Mode::VisualLine => &["visual"],
Mode::Insert => &[],
};
let mut cands: Vec<(usize, Hint)> = Vec::new();
for b in BINDINGS.iter().filter(|b| sections.contains(&b.section)) {
for seq in expand(b.keys) {
if let Some(key) = child_key(&seq, prefix) {
let len: usize = seq
.iter()
.map(|t| if *t == "space" { 1 } else { t.len() })
.sum();
cands.push((
len,
Hint {
key,
desc: b.desc,
live: b.live,
},
));
}
}
}
cands.sort_by_key(|(len, _)| *len); let mut out: Vec<Hint> = Vec::new();
for (_, h) in cands {
if !out.iter().any(|x| x.key == h.key) {
out.push(h);
}
}
out
}
fn child_key(seq: &[&str], prefix: &str) -> Option<String> {
let mut flat = String::new();
let mut bounds = Vec::new();
for t in seq {
bounds.push(flat.len());
flat.push_str(if *t == "space" { " " } else { t });
}
let plen = prefix.chars().count();
if plen == 0 || !flat.starts_with(prefix) || flat.chars().count() <= plen {
return None;
}
match bounds.iter().position(|b| *b == plen) {
Some(i) => Some(seq[i].to_string()),
None => {
let i = bounds.iter().rposition(|b| *b < plen)?;
Some(seq[i].chars().skip(plen - bounds[i]).collect())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::editor::{Editor, Mode};
use strop_core::Buffer;
#[test]
fn coverage_shape() {
for section in SECTIONS {
assert!(
BINDINGS.iter().any(|b| b.section == *section),
"section {section} is empty"
);
}
for b in BINDINGS {
assert!(!b.keys.is_empty() && !b.desc.is_empty());
assert!(
!b.desc.contains("(soon)"),
"{}: '(soon)' belongs to live:false, not the desc",
b.keys
);
}
}
#[test]
fn keys_notation_expands() {
assert_eq!(expand("space f"), vec![vec!["space", "f"]]);
assert_eq!(expand("space /"), vec![vec!["space", "/"]]);
assert_eq!(
expand("space g u / s / p"),
vec![
vec!["space", "g", "u"],
vec!["space", "g", "s"],
vec!["space", "g", "p"]
]
);
assert_eq!(expand("/ ?"), vec![vec!["/"], vec!["?"]]);
assert_eq!(expand("h j k l").len(), 4);
assert_eq!(
expand("ctrl-w h / l"),
vec![vec!["ctrl-w", "h"], vec!["ctrl-w", "l"]]
);
for b in BINDINGS {
let seqs = expand(b.keys);
assert!(!seqs.is_empty(), "{}: no sequences", b.keys);
assert!(seqs.iter().all(|s| !s.is_empty()));
}
}
#[test]
fn which_key_children() {
for p in [" ", " g", "g", "[", "]", "m", "'", "`"] {
assert!(
!children_of(p, Mode::Normal).is_empty(),
"no hints for pending {p:?}"
);
}
let space = children_of(" ", Mode::Normal);
let g = space.iter().find(|h| h.key == "g").expect("space g hint");
assert_eq!(g.desc, "git…");
assert!(g.live);
assert!(
space.iter().any(|h| h.key == "j" && !h.live),
"space j renders as soon"
);
let git = children_of(" g", Mode::Normal);
assert_eq!(git.len(), 8);
assert!(git.iter().any(|h| h.key == "u" && h.desc.contains("undo")));
assert_eq!(children_of("m", Mode::Normal)[0].key, "<a>");
assert_eq!(children_of("g", Mode::Normal).len(), 2); let v = children_of(" ", Mode::Visual);
assert_eq!(v.len(), 1);
assert_eq!(v[0].key, "y");
assert!(v[0].desc.contains("selection"));
}
const DISPATCHED: &[&str] = &[
"space f",
"space b",
"space /",
"space R",
"space ?",
"space d",
"space k",
"space y",
"space p",
"space P",
"space g u",
"space g s",
"space g p",
"space g l",
"space g h",
"space g b",
"space g y",
"space g o",
"space u",
"Q",
"space c",
"n",
"N",
"*",
"#",
";",
",",
"^",
"~",
"S",
"I",
"|",
"gg",
"gd",
"]c",
"[c",
"]f",
"[f",
"m<a>",
"'a",
"`a",
"r<c>",
"\"xy",
"\"xp",
"\"+y",
"\"+p",
"\"+P",
":w",
":q",
":q!",
":wq",
":e",
":help",
":e!",
":vs",
":vsplit",
":sp",
":split",
"ctrl-w h",
"ctrl-w l",
"ctrl-w j",
"ctrl-w k",
"ctrl-w w",
"ctrl-w v",
"ctrl-w s",
"ctrl-w q",
"up",
"down",
"tab",
"s-tab",
"ctrl-x",
"}",
"]",
")",
"enter",
"esc",
"backspace",
"q",
"S<c>",
"space y",
];
const EX_ALIASES: &[(&str, &str)] = &[(":vsplit", ":vs"), (":split", ":sp"), (":e!", ":e")];
#[test]
fn every_dispatched_sequence_has_a_row() {
for entry in DISPATCHED {
let canonical = EX_ALIASES
.iter()
.find(|(a, _)| a == entry)
.map(|(_, c)| *c)
.unwrap_or(*entry);
let want: Vec<&str> = canonical.split(' ').collect();
assert!(
BINDINGS.iter().any(|b| expand(b.keys).contains(&want)),
"{entry} dispatches but has no BINDINGS row (0003 §5.7)"
);
}
}
#[test]
fn live_leader_bindings_reach_dispatch() {
for b in BINDINGS
.iter()
.filter(|b| b.live && b.keys.starts_with("space"))
{
for seq in expand(b.keys) {
let keys = seq
.iter()
.map(|t| if *t == "space" { " " } else { t })
.collect::<String>();
let mut e = Editor::new(Buffer::from_text("x\n"));
e.feed_text(&keys);
assert!(
dispatched_something(&e),
"{} (fed as {keys:?}) was a no-op — table drift",
b.keys
);
}
}
}
fn dispatched_something(e: &Editor) -> bool {
!e.message.is_empty()
|| e.picker_open()
|| !e.pending.is_empty()
|| e.clip_paste_pending.is_some()
|| e.osc52.is_some()
|| e.mode != Mode::Normal
|| e.buffers.len() != 1
|| e.cursor != 0
|| e.hover_card.is_some()
|| e.blame_card.is_some()
}
#[test]
fn parameterized_leaves_reach_dispatch() {
let mut e = Editor::new(Buffer::from_text("one two\n"));
e.feed_text("ma");
assert_eq!(e.message, "mark a set");
e.feed_text("'b");
assert!(e.message.contains("not set"));
let mut e = Editor::new(Buffer::from_text("one two\n"));
e.feed_text("]c");
assert!(
!e.message.is_empty() || e.cursor != 0,
"]c must jump or report, never no-op"
);
let mut e = Editor::new(Buffer::from_text("one two\n"));
e.feed_text("gd");
assert!(!e.message.is_empty(), "gd with no LSP must say so");
let mut e = Editor::new(Buffer::from_text("hello world\n"));
e.feed_text("\"+yiw");
assert_eq!(e.register(Some('+')).0, "hello");
assert!(e.osc52.is_some(), "clipboard yank stages OSC52");
}
}