use std::collections::BTreeMap;
use serde::Deserialize;
use crate::input::{Key, KeyCode, KeySpec};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
NewPane,
SplitRight,
SplitDown,
ClosePane,
ToggleZoom,
ToggleFloating,
NewFloating,
FocusLeft,
FocusRight,
FocusUp,
FocusDown,
FocusOrTabLeft,
FocusOrTabRight,
ResizeMode,
ResizeLeft,
ResizeRight,
ResizeUp,
ResizeDown,
PanePicker,
RenamePane,
NewTab,
NextTab,
PrevTab,
CloseTab,
RenameTab,
Tab(u8),
WorkspacePicker,
NewWorkspace,
NextWorkspace,
PrevWorkspace,
RenameWorkspace,
CloseWorkspace,
SaveWorkspace,
AgentPicker,
NextAttention,
SwitchAccount,
SshPicker,
EditServers,
Upload,
Download,
Files,
OpenPreview,
ToolPicker,
Runbooks,
GitActions,
ScrollMode,
Search,
CopyLastOutput,
Hints,
ToggleSidebar,
Messages,
SelectForBroadcast,
BroadcastSelected,
BroadcastTab,
BroadcastWorkspace,
BroadcastOff,
BroadcastToggle,
CommandPalette,
Help,
Detach,
SendPrefix,
}
use Action::*;
const ACTIONS: &[(Action, &str, &str)] = &[
(NewPane, "new_pane", "New pane"),
(SplitRight, "split_right", "Split right"),
(SplitDown, "split_down", "Split down"),
(ClosePane, "close_pane", "Close pane"),
(ToggleZoom, "toggle_zoom", "Zoom pane"),
(ToggleFloating, "toggle_floating", "Floating panes: show or hide"),
(NewFloating, "new_floating", "New floating pane"),
(FocusLeft, "focus_left", "Focus left"),
(FocusRight, "focus_right", "Focus right"),
(FocusUp, "focus_up", "Focus up"),
(FocusDown, "focus_down", "Focus down"),
(FocusOrTabLeft, "focus_or_tab_left", "Focus left (previous tab at the edge)"),
(FocusOrTabRight, "focus_or_tab_right", "Focus right (next tab at the edge)"),
(ResizeMode, "resize_mode", "Resize panes"),
(ResizeLeft, "resize_left", "Resize left"),
(ResizeRight, "resize_right", "Resize right"),
(ResizeUp, "resize_up", "Resize up"),
(ResizeDown, "resize_down", "Resize down"),
(PanePicker, "pane_picker", "Find a pane"),
(RenamePane, "rename_pane", "Rename pane"),
(NewTab, "new_tab", "New tab"),
(NextTab, "next_tab", "Next tab"),
(PrevTab, "prev_tab", "Previous tab"),
(CloseTab, "close_tab", "Close tab"),
(RenameTab, "rename_tab", "Rename tab"),
(WorkspacePicker, "workspace_picker", "Choose a workspace"),
(NewWorkspace, "new_workspace", "New workspace"),
(NextWorkspace, "next_workspace", "Next workspace"),
(PrevWorkspace, "prev_workspace", "Previous workspace"),
(RenameWorkspace, "rename_workspace", "Rename workspace"),
(CloseWorkspace, "close_workspace", "Close workspace"),
(SaveWorkspace, "save_workspace", "Save workspace layout"),
(AgentPicker, "agent_picker", "Start an agent"),
(NextAttention, "next_attention", "Next agent needing you"),
(SwitchAccount, "switch_account", "Change the agent's account"),
(SshPicker, "ssh_picker", "Connect to a server"),
(EditServers, "edit_servers", "Edit saved servers"),
(Upload, "upload", "Upload to this pane's server"),
(Download, "download", "Download from this pane's server"),
(Files, "files", "File manager"),
(OpenPreview, "open_preview", "Preview a file"),
(ToolPicker, "tools", "Tools here (where this pane is)"),
(Runbooks, "runbooks", "Runbooks"),
(GitActions, "git_actions", "Git and worktrees"),
(ScrollMode, "scroll_mode", "Copy mode: scroll and select"),
(Search, "search", "Search the output"),
(CopyLastOutput, "copy_last_output", "Copy the last command's output"),
(Hints, "hints", "Open a link or path (hints on screen)"),
(ToggleSidebar, "toggle_sidebar", "Sidebar"),
(Messages, "messages", "Message log"),
(SelectForBroadcast, "select_for_broadcast", "Mark for broadcast (this pane)"),
(BroadcastSelected, "broadcast_selected", "Broadcast to the marked panes"),
(BroadcastTab, "broadcast_tab", "Broadcast to this tab"),
(BroadcastWorkspace, "broadcast_workspace", "Broadcast to this workspace"),
(BroadcastOff, "broadcast_off", "Stop broadcasting"),
(BroadcastToggle, "broadcast_toggle", "Broadcast on/off (this tab)"),
(CommandPalette, "command_palette", "Search commands"),
(Help, "help", "Help"),
(Detach, "detach", "Detach (programs keep running)"),
(SendPrefix, "send_prefix", "Send the prefix key"),
];
impl Action {
pub fn parse(name: &str) -> Option<Action> {
if let Some(&(a, _, _)) = ACTIONS.iter().find(|(_, n, _)| *n == name) {
return Some(a);
}
match name {
"toggle_sync" => return Some(BroadcastToggle),
"mark_pane" => return Some(SelectForBroadcast),
_ => {}
}
let n: u8 = name.strip_prefix("tab_")?.parse().ok()?;
(1..=9).contains(&n).then_some(Tab(n))
}
pub fn name(self) -> String {
match self {
Tab(n) => format!("tab_{n}"),
a => ACTIONS.iter().find(|(x, _, _)| *x == a).map(|(_, n, _)| n.to_string()).unwrap_or_default(),
}
}
pub fn describe(self) -> String {
match self {
Tab(n) => format!("Go to tab {n}"),
a => ACTIONS.iter().find(|(x, _, _)| *x == a).map(|(_, _, d)| d.to_string()).unwrap_or_default(),
}
}
pub fn label(self) -> String {
let d = self.describe();
let short = d.split(" (").next().unwrap_or(&d);
short.split(':').next().unwrap_or(short).trim().to_string()
}
pub fn all() -> impl Iterator<Item = Action> {
ACTIONS.iter().map(|(a, _, _)| *a).chain(std::iter::once(Tab(1)))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Action(Action),
Tool(String),
}
type Table = &'static [(&'static str, &'static [&'static str])];
type MenuDef = (&'static str, &'static str, &'static str, Action);
pub struct Preset {
pub name: &'static str,
leader: Table,
direct: Table,
menus: &'static [MenuDef],
tool_menu: &'static str,
}
pub const KRAAL: Preset = Preset {
name: "kraal",
leader: &[
("ssh_picker", &["s", "{servers} s", "{servers} enter"]),
("broadcast_toggle", &["b", "{broadcast} b", "{broadcast} enter"]),
("workspace_picker", &["w", "{workspaces} w", "{workspaces} enter"]),
("agent_picker", &["a", "{agents} a", "{agents} enter"]),
("new_tab", &["t", "{tabs} t", "{tabs} n", "{tabs} enter"]),
("pane_picker", &["p", "{panes} p", "{panes} enter"]),
("scroll_mode", &["v", "{view} v", "{view} enter"]),
("files", &["f", "{files} f", "{files} enter"]),
("tools", &["r", "{run} r", "{run} enter"]),
("new_pane", &["n", "{panes} n"]),
("close_pane", &["x", "{panes} x"]),
("toggle_zoom", &["z"]),
("select_for_broadcast", &["m", "{broadcast} m"]),
("hints", &["o", "{view} o"]),
("search", &["g", "/", "{view} g"]),
("focus_left", &["left"]),
("focus_down", &["down"]),
("focus_up", &["up"]),
("focus_right", &["right"]),
("tab_1", &["1"]),
("tab_2", &["2"]),
("tab_3", &["3"]),
("tab_4", &["4"]),
("tab_5", &["5"]),
("tab_6", &["6"]),
("tab_7", &["7"]),
("tab_8", &["8"]),
("tab_9", &["9"]),
("command_palette", &["space"]),
("help", &["h", "?"]),
("detach", &["d"]),
("send_prefix", &["{prefix}"]),
("split_right", &["{panes} right"]),
("split_down", &["{panes} down"]),
("rename_pane", &["{panes} e"]),
("resize_mode", &["{panes} r"]),
("new_floating", &["{panes} f"]),
("prev_tab", &["{tabs} left"]),
("next_tab", &["{tabs} right"]),
("rename_tab", &["{tabs} e"]),
("close_tab", &["{tabs} x"]),
("new_workspace", &["{workspaces} n"]),
("prev_workspace", &["{workspaces} left"]),
("next_workspace", &["{workspaces} right"]),
("rename_workspace", &["{workspaces} e"]),
("save_workspace", &["{workspaces} s"]),
("close_workspace", &["{workspaces} x"]),
("next_attention", &["{agents} g"]),
("switch_account", &["{agents} c"]),
("upload", &["{servers} u", "{files} u"]),
("download", &["{servers} d", "{files} d"]),
("edit_servers", &["{servers} e"]),
("open_preview", &["{files} p"]),
("runbooks", &["{run} b"]),
("git_actions", &["{run} g"]),
("toggle_floating", &["{view} f"]),
("toggle_sidebar", &["{view} l"]),
("messages", &["{view} j"]),
("broadcast_workspace", &["{broadcast} w"]),
("broadcast_selected", &["{broadcast} s"]),
("broadcast_off", &["{broadcast} o"]),
("copy_last_output", &["{view} c"]),
],
direct: &[
("focus_or_tab_left", &["alt+left"]),
("focus_or_tab_right", &["alt+right"]),
("focus_up", &["alt+up"]),
("focus_down", &["alt+down"]),
("new_pane", &["alt+n"]),
("next_attention", &["alt+a"]),
],
menus: &[
("servers", "S", "Servers", SshPicker),
("broadcast", "B", "Broadcast", BroadcastToggle),
("workspaces", "W", "Workspaces", WorkspacePicker),
("agents", "A", "Agents", AgentPicker),
("tabs", "T", "Tabs", NewTab),
("panes", "P", "Panes", PanePicker),
("view", "V", "View", ScrollMode),
("files", "F", "Files", Files),
("run", "R", "Run", ToolPicker),
],
tool_menu: "{run}",
};
pub const CLASSIC: Preset = Preset {
name: "classic",
leader: &[
("split_right", &["%", "|"]),
("split_down", &["\"", "-"]),
("close_pane", &["x"]),
("toggle_zoom", &["z"]),
("toggle_floating", &["t"]),
("new_floating", &["F"]),
("files", &["f"]),
("focus_left", &["h", "left"]),
("focus_right", &["l", "right"]),
("focus_up", &["k", "up"]),
("focus_down", &["j", "down"]),
("resize_left", &["H", "ctrl+left"]),
("resize_right", &["L", "ctrl+right"]),
("resize_up", &["K", "ctrl+up"]),
("resize_down", &["J", "ctrl+down"]),
("new_tab", &["c"]),
("next_tab", &["n", "tab"]),
("prev_tab", &["p", "shift+tab"]),
("close_tab", &["&"]),
("rename_tab", &[","]),
("rename_pane", &["."]),
("rename_workspace", &["$"]),
("tab_1", &["1"]),
("tab_2", &["2"]),
("tab_3", &["3"]),
("tab_4", &["4"]),
("tab_5", &["5"]),
("tab_6", &["6"]),
("tab_7", &["7"]),
("tab_8", &["8"]),
("tab_9", &["9"]),
("next_workspace", &[")"]),
("prev_workspace", &["("]),
("workspace_picker", &["o", "w"]),
("ssh_picker", &["s"]),
("upload", &["U"]),
("download", &["D"]),
("pane_picker", &["g"]),
("agent_picker", &["G"]),
("switch_account", &["A"]),
("git_actions", &["R"]),
("tools", &["T"]),
("hints", &["space"]),
("runbooks", &["r"]),
("next_attention", &["a"]),
("toggle_sidebar", &["b"]),
("broadcast_toggle", &["y"]),
("select_for_broadcast", &["m"]),
("scroll_mode", &["[", "pageup", "v"]),
("search", &["/"]),
("open_preview", &["e"]),
("save_workspace", &["S"]),
("command_palette", &[":"]),
("detach", &["d", "q"]),
("help", &["?"]),
("send_prefix", &["{prefix}"]),
("copy_last_output", &["Y"]),
],
direct: &[
("focus_or_tab_left", &["alt+h", "alt+left"]),
("focus_or_tab_right", &["alt+l", "alt+right"]),
("focus_up", &["alt+k", "alt+up"]),
("focus_down", &["alt+j", "alt+down"]),
("new_pane", &["alt+n"]),
("toggle_floating", &["alt+f"]),
("next_attention", &["alt+a"]),
("workspace_picker", &["alt+o"]),
("agent_picker", &["alt+g"]),
],
menus: &[],
tool_menu: "",
};
pub const PRESETS: [&Preset; 2] = [&KRAAL, &CLASSIC];
#[derive(Debug, Clone, Deserialize)]
pub struct KeysConfig {
pub preset: String,
pub prefix: Vec<String>,
#[serde(default)]
pub leader: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub prefixed: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub direct: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, PartialEq)]
pub enum Lookup {
Run(Command),
More,
Nothing,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MenuEntry {
pub key: KeySpec,
pub label: String,
pub group: bool,
pub command: Option<Command>,
pub main: bool,
}
#[derive(Debug, Clone)]
pub struct Menu {
pub id: &'static str,
pub keys: Vec<KeySpec>,
pub title: &'static str,
pub main: Action,
}
fn strip_word(text: &str, word: &str) -> Option<String> {
let head = text.get(..word.len())?;
let rest = text[word.len()..].strip_prefix(' ')?;
let mut chars = rest.chars();
let first = chars.next()?;
head.eq_ignore_ascii_case(word).then(|| first.to_uppercase().chain(chars).collect())
}
#[derive(Debug, Clone, Default)]
pub struct Keymap {
pub preset: &'static str,
pub prefix: Vec<KeySpec>,
pub direct: Vec<(KeySpec, Action)>,
pub leader: Vec<(Vec<KeySpec>, Command)>,
pub menus: Vec<Menu>,
tool_menu: Vec<Vec<KeySpec>>,
}
fn expand(text: &str, menus: &[Menu], prefix: &[KeySpec]) -> Result<Vec<Vec<KeySpec>>, String> {
let mut out: Vec<Vec<KeySpec>> = vec![Vec::new()];
let mut empty = true;
for token in text.split_whitespace() {
empty = false;
let named = token.strip_prefix('{').and_then(|t| t.strip_suffix('}'));
let choices: Vec<KeySpec> = match named {
Some("prefix") => prefix.to_vec(),
Some(id) => menus
.iter()
.find(|m| m.id == id)
.map(|m| m.keys.clone())
.ok_or_else(|| format!("{token:?}: no such menu"))?,
None => vec![KeySpec::parse(token).map_err(|e| format!("{token:?}: {e}"))?],
};
out = out
.into_iter()
.flat_map(|seq| {
choices.iter().map(move |k| {
let mut seq = seq.clone();
seq.push(k.clone());
seq
})
})
.collect();
}
if empty {
return Err("empty".into());
}
Ok(out)
}
fn bindings(
preset: Table,
user: &[&BTreeMap<String, Vec<String>>],
ctx: &str,
(menus, prefix): (&[Menu], &[KeySpec]),
warnings: &mut Vec<String>,
) -> Vec<(Vec<KeySpec>, Action)> {
let is_menu = |name: &str| name.starts_with("menu_");
let overridden: Vec<&str> = user.iter().flat_map(|m| m.keys()).map(String::as_str).collect();
let mut out = Vec::new();
let mut add = |name: &str, seqs: &[String], warnings: &mut Vec<String>| {
let Some(action) = Action::parse(name) else {
warnings.push(format!("{ctx}: unknown action {name:?}"));
return;
};
for s in seqs {
match expand(s, menus, prefix) {
Ok(all) => out.extend(all.into_iter().map(|keys| (keys, action))),
Err(e) => warnings.push(format!("{ctx}.{name}: invalid key {e}")),
}
}
};
for (name, seqs) in preset.iter().filter(|(n, _)| !overridden.contains(n)) {
let seqs: Vec<String> = seqs.iter().map(|s| s.to_string()).collect();
add(name, &seqs, warnings);
}
for map in user {
for (name, seqs) in map.iter().filter(|(n, _)| !is_menu(n)) {
add(name, seqs, warnings);
}
}
out
}
pub type Section = (String, Vec<(String, String)>);
pub const MODE_KEYS: &[(&str, &[(&str, &str)])] = &[
(
"In lists",
&[
("type", "filter"),
("↑ ↓", "move"),
("⏎", "choose"),
("tab", "more actions on the selected item"),
("esc", "close"),
],
),
(
"In text fields",
&[
("← → home end", "move (alt+← → by word)"),
("ctrl+w ctrl+u ctrl+k", "delete a word, to the start, to the end"),
("⏎ / esc", "accept / cancel"),
],
),
(
"In questions",
&[
("y / n", "yes / no"),
("← → tab", "move between the answers"),
("⏎", "the selected answer"),
("↑ ↓", "scroll a long question"),
("esc", "no"),
],
),
(
"Copy mode",
&[
("arrows hjkl", "move"),
("w b e", "next word, previous word, end of word"),
("0 $ home end", "start, end of the line"),
("g G", "top, bottom"),
("pageup pagedown", "a page up, down (ctrl+u ctrl+d: half a page)"),
("v V ctrl+v", "select characters, lines, a block"),
("y or ⏎", "copy and leave"),
("/ n N", "search, next, previous"),
("p P", "the prompt above, below (shells that mark commands)"),
("o O", "select a command's output, the whole command"),
("esc q", "leave"),
],
),
(
"Searching the output",
&[
("type", "the text to find"),
("↑ ↓", "older, newer match"),
("⏎", "keep the match and go on in copy mode"),
("esc", "cancel"),
],
),
("Resizing", &[("arrows hjkl", "move the border"), ("⏎ or esc", "done")]),
(
"Hints",
&[("a label", "copy what it marks, or act on it"), ("tab", "switch between copy and act"), ("esc", "cancel")],
),
(
"Mouse",
&[
("click", "focus a pane, tab, workspace or agent"),
("drag a border", "resize panes"),
("drag in a pane", "select and copy (double: word, triple: line)"),
("ctrl+click", "open a link; on a frame's top: mark for broadcast"),
("shift + mouse", "your terminal's own selection"),
],
),
];
fn same_key(a: &KeySpec, b: &KeySpec) -> bool {
a.matches(&Key { code: b.code, mods: b.mods, raw: Vec::new() })
|| b.matches(&Key { code: a.code, mods: a.mods, raw: Vec::new() })
}
fn starts_with(seq: &[KeySpec], typed: &[Key]) -> bool {
seq.len() >= typed.len() && seq.iter().zip(typed).all(|(s, k)| s.matches(k))
}
impl Keymap {
pub fn build(cfg: &KeysConfig) -> (Keymap, Vec<String>) {
let mut warnings = Vec::new();
let preset = PRESETS.iter().find(|p| p.name == cfg.preset).copied().unwrap_or_else(|| {
warnings.push(format!("keys.preset: no preset {:?} (kraal or classic)", cfg.preset));
&KRAAL
});
let prefix: Vec<KeySpec> = cfg
.prefix
.iter()
.filter_map(|s| {
KeySpec::parse(s).map_err(|e| warnings.push(format!("keys.prefix: invalid key {s:?}: {e}"))).ok()
})
.collect();
let user = [&cfg.prefixed, &cfg.leader];
let mut menus = Vec::new();
for &(id, key, title, main) in preset.menus {
let name = format!("menu_{id}");
let keys = match user.iter().rev().find_map(|m| m.get(&name)) {
Some(list) => list
.iter()
.filter_map(|k| {
KeySpec::parse(k).map_err(|e| warnings.push(format!("keys.leader.{name}: {e}"))).ok()
})
.collect(),
None => KeySpec::parse(key).into_iter().collect(),
};
menus.push(Menu { id, keys, title, main });
}
for name in user.iter().flat_map(|m| m.keys()).filter(|n| n.starts_with("menu_")) {
if !menus.iter().any(|m| name[5..] == *m.id) {
warnings.push(format!("keys.leader: no menu {name:?} in the {} preset", preset.name));
}
}
let direct: Vec<(KeySpec, Action)> =
bindings(preset.direct, &[&cfg.direct], "keys.direct", (&menus, &prefix), &mut warnings)
.into_iter()
.filter_map(|(mut keys, a)| {
if keys.len() > 1 {
warnings.push(format!("keys.direct.{}: one key, not a sequence", a.name()));
return None;
}
Some((keys.remove(0), a))
})
.collect();
let mut kept: Vec<(KeySpec, Action)> = Vec::new();
for (k, a) in direct {
if prefix.iter().any(|p| same_key(p, &k)) {
warnings.push(format!("keys.direct.{}: {k} is the prefix key", a.name()));
} else if let Some((_, other)) = kept.iter().find(|(x, _)| same_key(x, &k)) {
warnings.push(format!("keys.direct.{}: {k} is already {:?}", a.name(), other.describe()));
} else {
kept.push((k, a));
}
}
let leader = bindings(preset.leader, &user, "keys.leader", (&menus, &prefix), &mut warnings);
let tool_menu = match preset.tool_menu {
"" => vec![Vec::new()],
t => expand(t, &menus, &prefix).unwrap_or_default(),
};
let mut km = Keymap { preset: preset.name, prefix, direct: kept, menus, tool_menu, ..Keymap::default() };
for (keys, a) in leader {
km.add(keys, Command::Action(a), &mut warnings);
}
(km, warnings)
}
fn add(&mut self, keys: Vec<KeySpec>, command: Command, warnings: &mut Vec<String>) -> bool {
let shown = |k: &[KeySpec]| k.iter().map(KeySpec::to_string).collect::<Vec<_>>().join(" ");
let what = |c: &Command| match c {
Command::Action(a) => a.describe(),
Command::Tool(t) => format!("tool {t}"),
};
for (other, c) in &self.leader {
let n = other.len().min(keys.len());
if other[..n].iter().zip(&keys[..n]).all(|(a, b)| same_key(a, b)) {
warnings.push(if other.len() == keys.len() {
format!("key {:?} is already {:?}", shown(&keys), what(c))
} else {
format!("key {:?} clashes with {:?} ({})", shown(&keys), shown(other), what(c))
});
return false;
}
}
self.leader.push((keys, command));
true
}
pub fn bind_tools(&mut self, tools: &BTreeMap<String, crate::tools::ToolDef>) -> Vec<String> {
let mut warnings = Vec::new();
for (name, t) in tools.iter().filter(|(_, t)| t.enabled) {
let Some(key) = &t.key else { continue };
match KeySpec::parse(key) {
Ok(k) => {
for menu in self.tool_menu.clone() {
let mut keys = menu;
keys.push(k.clone());
let mut why = Vec::new();
if !self.add(keys, Command::Tool(name.clone()), &mut why) {
warnings.extend(why.into_iter().map(|w| format!("tools.{name}: {w}")));
}
}
}
Err(e) => warnings.push(format!("tools.{name}: invalid key {key:?}: {e}")),
}
}
warnings
}
pub fn is_prefix(&self, key: &Key) -> bool {
let prefix = |k: &Key| self.prefix.iter().any(|p| p.matches(k));
prefix(key) || (self.direct_typed(key).is_none() && key.by_place().is_some_and(|k| prefix(&k)))
}
pub fn direct(&self, key: &Key) -> Option<Action> {
self.direct_typed(key).or_else(|| {
let typed_prefix = self.prefix.iter().any(|p| p.matches(key));
key.by_place().filter(|_| !typed_prefix).and_then(|k| self.direct_typed(&k))
})
}
fn direct_typed(&self, key: &Key) -> Option<Action> {
self.direct.iter().find(|(k, _)| k.matches(key)).map(|(_, a)| *a)
}
pub fn leader_key(&self, typed: &[Key], key: Key) -> Key {
let reads = |k: &Key| {
let mut keys = typed.to_vec();
keys.push(k.clone());
self.lookup(&keys) != Lookup::Nothing
};
if reads(&key) {
return key;
}
let place = key.by_place().or_else(|| digit_row(&key));
match place {
Some(place) if reads(&place) => place,
_ => key,
}
}
pub fn lookup(&self, typed: &[Key]) -> Lookup {
let mut more = false;
for (seq, cmd) in &self.leader {
if !starts_with(seq, typed) {
continue;
}
if seq.len() == typed.len() {
return Lookup::Run(cmd.clone());
}
more = true;
}
if more { Lookup::More } else { Lookup::Nothing }
}
pub fn menu(&self, typed: &[Key]) -> Vec<MenuEntry> {
let typed_specs: Vec<KeySpec> = typed.iter().map(|k| KeySpec::new(k.code, k.mods)).collect();
let within = self.group_title(&typed_specs);
let mut out: Vec<MenuEntry> = Vec::new();
for (seq, cmd) in &self.leader {
if seq.len() <= typed.len() || !starts_with(seq, typed) {
continue;
}
let key = seq[typed.len()].clone();
let group = seq.len() > typed.len() + 1;
if out.iter().any(|e| e.key == key || (!group && e.command.as_ref() == Some(cmd))) {
continue;
}
let label = if group {
self.group_title(&seq[..=typed.len()]).unwrap_or("…").to_string()
} else {
let label = match cmd {
Command::Action(a) => a.label(),
Command::Tool(t) => t.clone(),
};
match within.and_then(|title| strip_word(&label, title)) {
Some(rest) => rest,
None => label,
}
};
let main = typed.is_empty() && !group && matches!(cmd, Command::Action(a) if self.is_main(*a));
out.push(MenuEntry { key, label, group, command: (!group).then(|| cmd.clone()), main });
}
out.sort_by_key(|e| (e.group, !e.main));
out
}
pub fn is_main(&self, action: Action) -> bool {
self.menus.iter().any(|m| m.main == action && !m.keys.is_empty())
}
pub fn group_title(&self, keys: &[KeySpec]) -> Option<&'static str> {
match keys {
[k] => self.menus.iter().find(|m| m.keys.contains(k)).map(|m| m.title),
_ => None,
}
}
pub fn menu_hint(&self, id: &str) -> Option<String> {
let key = self.menus.iter().find(|m| m.id == id)?.keys.first()?;
Some(format!("{} {key}", self.prefix_label()))
}
pub fn reference(&self, ascii: bool) -> Vec<Section> {
let p = self.prefix_label();
let seq = |keys: &[KeySpec]| {
let mut parts = vec![p.clone()];
parts.extend(keys.iter().map(|k| pretty(k, ascii)));
parts.join(" ")
};
let root = format!("After {p}");
let mut sections: Vec<(Section, Vec<Command>)> = vec![((root.clone(), Vec::new()), Vec::new())];
for (keys, cmd) in &self.leader {
let title = match keys.len() {
1 => root.clone(),
_ => match self.group_title(&keys[..1]) {
Some(t) => format!("{t} ({p} {})", pretty(&keys[0], ascii)),
None => format!("{p} {} …", pretty(&keys[0], ascii)),
},
};
let row = match cmd {
Command::Action(Tab(n)) if *n > 1 && keys.len() == 1 => None,
Command::Action(FocusDown | FocusRight | FocusUp) if keys.len() == 1 => None,
Command::Action(FocusLeft) if keys.len() == 1 => Some((
format!("{p} {}", if ascii { "arrows" } else { "←↓↑→" }),
"Focus the pane on that side".to_string(),
)),
Command::Action(Tab(_)) if keys.len() == 1 => Some((format!("{p} 1…9"), "Go to tab 1-9".to_string())),
Command::Action(a) => Some((seq(keys), a.describe())),
Command::Tool(t) => Some((seq(keys), format!("Tool: {t}"))),
};
let Some(row) = row else { continue };
match sections.iter_mut().find(|((t, _), _)| *t == title) {
Some(((_, rows), seen)) => {
if !seen.contains(cmd) {
seen.push(cmd.clone());
rows.push(row);
}
}
None => sections.push(((title, vec![row]), vec![cmd.clone()])),
}
}
let mut out: Vec<Section> =
sections.into_iter().map(|(section, _)| section).filter(|(_, rows)| !rows.is_empty()).collect();
let direct: Vec<(String, String)> = self.direct.iter().map(|(k, a)| (k.to_string(), a.describe())).collect();
if !direct.is_empty() {
out.push(("Without the prefix".into(), direct));
}
for (title, rows) in MODE_KEYS {
let shown = |k: &str| {
if !ascii {
return k.to_string();
}
[('⏎', "enter"), ('↑', "up"), ('↓', "down"), ('←', "left"), ('→', "right")]
.iter()
.fold(k.to_string(), |s, (from, to)| s.replace(*from, to))
};
out.push((title.to_string(), rows.iter().map(|(k, w)| (shown(k), w.to_string())).collect()));
}
out
}
pub fn hint(&self, action: Action) -> Option<String> {
if let Some((k, _)) = self.direct.iter().find(|(_, a)| *a == action) {
return Some(k.to_string());
}
self.leader_hint(&Command::Action(action))
}
pub fn leader_hint(&self, command: &Command) -> Option<String> {
let (seq, _) = self.leader.iter().find(|(_, c)| c == command)?;
let mut parts = vec![self.prefix_label()];
parts.extend(seq.iter().map(KeySpec::to_string));
Some(parts.join(" "))
}
pub fn all_hints(&self, command: &Command) -> Vec<String> {
let mut out: Vec<String> = match command {
Command::Action(a) => self.direct.iter().filter(|(_, x)| x == a).map(|(k, _)| k.to_string()).collect(),
Command::Tool(_) => Vec::new(),
};
let prefix = self.prefix_label();
out.extend(
self.leader.iter().filter(|(_, c)| c == command).map(|(seq, _)| {
format!("{prefix} {}", seq.iter().map(KeySpec::to_string).collect::<Vec<_>>().join(" "))
}),
);
out
}
pub fn prefix_label(&self) -> String {
self.prefix.first().map(|k| k.to_string()).unwrap_or_else(|| "prefix".into())
}
}
pub fn pretty(key: &KeySpec, ascii: bool) -> String {
if !ascii && key.mods == Default::default() {
let arrow = match key.code {
KeyCode::Left => Some("←"),
KeyCode::Right => Some("→"),
KeyCode::Up => Some("↑"),
KeyCode::Down => Some("↓"),
KeyCode::Enter => Some("⏎"),
_ => None,
};
if let Some(a) = arrow {
return a.to_string();
}
}
key.to_string()
}
fn digit_row(key: &Key) -> Option<Key> {
let KeyCode::Char(c) = key.code else { return None };
if key.has_place() || key.mods.ctrl || key.mods.alt || key.mods.shift {
return None;
}
let digit = match c {
'&' => '1',
'é' => '2',
'"' => '3',
'\'' => '4',
'(' => '5',
'-' | '§' => '6',
'è' => '7',
'_' | '!' => '8',
'ç' => '9',
'à' => '0',
_ => return None,
};
Some(Key { code: KeyCode::Char(digit), mods: crate::input::Mods::NONE, raw: key.raw.clone() })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::Mods;
fn cfg(user: &str) -> crate::config::Config {
crate::config::Config::from_user_toml(user).unwrap()
}
fn key(c: char) -> Key {
Key { code: KeyCode::Char(c), mods: Mods::NONE, raw: vec![c as u8] }
}
#[test]
fn presets_bind_cleanly() {
for preset in PRESETS {
let (km, warnings) = Keymap::build(&cfg(&format!("[keys]\npreset = \"{}\"\n", preset.name)).keys);
assert!(warnings.is_empty(), "{}: {warnings:?}", preset.name);
assert!(km.leader.iter().any(|(_, c)| *c == Command::Action(Tab(9))));
assert!(km.leader.iter().any(|(_, c)| *c == Command::Action(Help)));
assert!(km.direct.iter().any(|(_, a)| *a == NextAttention));
}
for (a, name, _) in ACTIONS {
assert_eq!(Action::parse(name), Some(*a));
}
}
#[test]
fn a_theme_letter_runs_its_main_action_and_its_capital_opens_its_menu() {
let (km, _) = Keymap::build(&cfg("").keys);
assert_eq!(km.preset, "kraal");
let run = |a: Action| Lookup::Run(Command::Action(a));
for (c, a) in [
('s', SshPicker),
('b', BroadcastToggle),
('w', WorkspacePicker),
('a', AgentPicker),
('t', NewTab),
('p', PanePicker),
('v', ScrollMode),
('f', Files),
('r', ToolPicker),
] {
assert_eq!(km.lookup(&[key(c)]), run(a), "{c}");
let menu = key(c.to_ascii_uppercase());
assert_eq!(km.lookup(std::slice::from_ref(&menu)), Lookup::More, "{c}");
assert_eq!(km.lookup(&[menu, key(c)]), run(a), "{c} in its menu");
}
for (c, a) in [('n', NewPane), ('x', ClosePane), ('z', ToggleZoom), ('m', SelectForBroadcast), ('o', Hints)] {
assert_eq!(km.lookup(&[key(c)]), run(a), "{c}");
}
assert_eq!(km.lookup(&[key('g')]), run(Search));
assert_eq!(km.lookup(&[key('/')]), run(Search));
assert_eq!(km.lookup(&[key('?')]), run(Help));
let right = Key { code: KeyCode::Right, mods: Mods::NONE, raw: Vec::new() };
assert_eq!(km.lookup(&[key('P'), right]), run(SplitRight));
assert_eq!(km.lookup(&[key('q')]), Lookup::Nothing);
for menu in ['P', 'T', 'W'] {
assert!(matches!(km.lookup(&[key(menu), key('x')]), Lookup::Run(_)), "{menu} x");
assert!(matches!(km.lookup(&[key(menu), key('e')]), Lookup::Run(_)), "{menu} e");
}
}
#[test]
fn the_menu_lists_main_actions_then_keys_then_menus() {
let (km, _) = Keymap::build(&cfg("").keys);
let root = km.menu(&[]);
let first_plain = root.iter().position(|e| !e.main).unwrap();
let first_menu = root.iter().position(|e| e.group).unwrap();
assert!(root[..first_plain].iter().all(|e| e.main) && first_plain == 9, "{root:?}");
assert!(root[first_plain..first_menu].iter().all(|e| !e.main && !e.group));
assert!(root[first_menu..].iter().all(|e| e.group));
assert_eq!(root[0].label, "Connect to a server");
let panes = root.iter().find(|e| e.key == KeySpec::parse("P").unwrap()).unwrap();
assert!(panes.group && panes.label == "Panes");
assert!(km.menu(&[key('P')]).iter().any(|e| e.label == "Resize panes"));
let b: Vec<String> = km.menu(&[key('B')]).into_iter().map(|e| e.label).collect();
assert_eq!(b[0], "On/off");
assert!(b.contains(&"To this workspace".to_string()) && b.contains(&"Stop broadcasting".to_string()), "{b:?}");
assert_eq!(km.hint(SplitRight).as_deref(), Some("ctrl+b P right"));
assert_eq!(km.hint(SshPicker).as_deref(), Some("ctrl+b s"));
assert_eq!(km.hint(NewPane).as_deref(), Some("alt+n"));
assert_eq!(km.menu_hint("broadcast").as_deref(), Some("ctrl+b B"));
let reference = km.reference(false);
assert!(reference.iter().any(|(t, _)| t == "Servers (ctrl+b S)"), "{reference:?}");
assert!(reference.iter().any(|(t, _)| t == "Copy mode"));
}
#[test]
fn the_prefix_twice_sends_it_whatever_it_is() {
let (km, warnings) = Keymap::build(&cfg("[keys]\nprefix = [\"ctrl+a\", \"ctrl+space\"]\n").keys);
assert!(warnings.is_empty(), "{warnings:?}");
let ctrl = |c: char| Key { code: KeyCode::Char(c), mods: Mods::CTRL, raw: Vec::new() };
assert_eq!(km.lookup(&[ctrl('a')]), Lookup::Run(Command::Action(SendPrefix)));
assert_eq!(km.lookup(&[ctrl(' ')]), Lookup::Run(Command::Action(SendPrefix)));
assert_eq!(km.lookup(&[ctrl('b')]), Lookup::Nothing);
}
#[test]
fn menus_move_like_actions() {
let (km, warnings) = Keymap::build(&cfg("[keys.leader]\nmenu_servers = [\"M\"]\nmenu_view = []\n").keys);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(km.lookup(&[key('M'), key('u')]), Lookup::Run(Command::Action(Upload)));
assert_eq!(km.lookup(&[key('S')]), Lookup::Nothing);
assert_eq!(km.group_title(&[KeySpec::parse("M").unwrap()]), Some("Servers"));
assert_eq!(km.lookup(&[key('V')]), Lookup::Nothing);
assert_eq!(km.lookup(&[key('v')]), Lookup::Run(Command::Action(ScrollMode)));
assert!(!km.is_main(ScrollMode));
let (_, warnings) = Keymap::build(&cfg("[keys.leader]\nmenu_nope = [\"N\"]\n").keys);
assert_eq!(warnings.len(), 1, "{warnings:?}");
let (_, warnings) = Keymap::build(&cfg("[keys.leader]\nmenu_tabs = [\"n\"]\n").keys);
assert!(!warnings.is_empty());
}
#[test]
fn user_bindings_replace_the_preset_and_collisions_are_reported() {
let (km, warnings) = Keymap::build(&cfg("[keys.leader]\nsplit_right = [\"%\"]\nnew_tab = [\"P\"]\n").keys);
assert_eq!(km.lookup(&[key('%')]), Lookup::Run(Command::Action(SplitRight)));
assert!(km.all_hints(&Command::Action(SplitRight)).iter().all(|h| !h.contains("right")));
assert_eq!(warnings.len(), 1, "P is the Panes menu: {warnings:?}");
let (km, warnings) = Keymap::build(&cfg("[keys.prefixed]\ntoggle_sync = [\"y\"]\n").keys);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(km.lookup(&[key('y')]), Lookup::Run(Command::Action(BroadcastToggle)));
let (_, warnings) = Keymap::build(&cfg("[keys]\npreset = \"emacs\"\n").keys);
assert_eq!(warnings.len(), 1);
}
#[test]
fn tools_go_in_the_run_menu() {
let c = cfg("[tools.lazygit]\nkey = \"l\"\n[tools.mine]\ncommand = \"tig\"\nkey = \"g\"\n");
let (mut km, _) = Keymap::build(&c.keys);
let warnings = km.bind_tools(&c.tools);
assert_eq!(warnings.len(), 1, "R g is git: {warnings:?}");
assert_eq!(km.lookup(&[key('R'), key('l')]), Lookup::Run(Command::Tool("lazygit".into())));
let classic_cfg = cfg(
"[keys]\npreset = \"classic\"\n[tools.lazygit]\nkey = \"i\"\n[tools.mine]\ncommand = \"tig\"\nkey = \"l\"\n",
);
let (mut classic, _) = Keymap::build(&classic_cfg.keys);
let warnings = classic.bind_tools(&classic_cfg.tools);
assert!(warnings.len() == 1 && warnings[0].contains("mine"), "l is focus right: {warnings:?}");
assert_eq!(classic.lookup(&[key('i')]), Lookup::Run(Command::Tool("lazygit".into())));
assert_eq!(classic.lookup(&[key('l')]), Lookup::Run(Command::Action(FocusRight)));
let c = cfg("[tools.mine]\ncommand = \"tig\"\nkey = \"ctrl+m\"\n");
let (mut km, _) = Keymap::build(&c.keys);
assert_eq!(km.bind_tools(&c.tools).len(), 1, "R ctrl+m is R enter");
let (_, warnings) = Keymap::build(&cfg("[keys.direct]\nnew_tab = [\"ctrl+b\"]\n").keys);
assert!(warnings.iter().any(|w| w.contains("prefix")), "{warnings:?}");
}
#[test]
fn keys_by_their_place_on_the_keyboard() {
let received = |bytes: &[u8]| match crate::input::InputParser::new().feed(bytes).as_slice() {
[crate::input::InputEvent::Key(k)] => k.clone(),
other => panic!("{other:?}"),
};
let (km, _) = Keymap::build(&cfg("[keys.leader]\nnew_tab = [\"k\"]\n").keys);
let amp = received(b"\x1b[38::49;;38u");
let one = km.leader_key(&[], amp.clone());
assert_eq!(km.lookup(&[one]), Lookup::Run(Command::Action(Tab(1))));
let (classic, _) = Keymap::build(&cfg("[keys]\npreset = \"classic\"\n").keys);
let typed = classic.leader_key(&[], amp);
assert_eq!(classic.lookup(&[typed]), Lookup::Run(Command::Action(CloseTab)));
let l = km.leader_key(&[], received(b"\x1b[1083::107;;1083u"));
assert_eq!(km.lookup(&[l]), Lookup::Run(Command::Action(NewTab)));
assert!(km.is_prefix(&received(b"\x1b[1080::98;5u")));
assert!(!km.is_prefix(&received(b"\x1b[1080;5u")), "without its place, a Russian letter is itself");
assert_eq!(km.direct(&received(b"\x1b[1090::110;3u")), Some(NewPane));
let a = km.leader_key(&[], received(b"\x1b[97::113;;97u"));
assert_eq!(a.code, KeyCode::Char('a'));
let one = km.leader_key(&[], received(b"&"));
assert_eq!(km.lookup(&[one]), Lookup::Run(Command::Action(Tab(1))));
let typed = classic.leader_key(&[], received(b"&"));
assert_eq!(classic.lookup(&[typed]), Lookup::Run(Command::Action(CloseTab)));
let shifted = km.leader_key(&[], received(b"\x1b[55:38:55;2;38u"));
assert_ne!(km.lookup(&[shifted]), Lookup::Run(Command::Action(Tab(1))));
}
}