use std::collections::HashMap;
use anyhow::{bail, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[cfg(feature = "git")]
use crate::app::GitCopyKind;
use crate::app::{CopyKind, SortKey};
use crate::i18n::Msg;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Motion {
Up,
Down,
Top,
Bottom,
PageUp,
PageDown,
HalfUp,
HalfDown,
Left,
Right,
LineHome,
LineEnd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Noop,
Navigate(Motion),
TabNew,
TabClose,
TabPrev,
TabNext,
TabGoto(u8),
ToggleHelp,
CopyPath(CopyKind),
Quit,
FilterStart,
TreeDescend,
TreeActivate,
TreeLeave,
ToggleHidden,
ToggleInfo,
RequestEdit,
OpenGitView,
Refresh,
CyclePathStyle,
OpenSortMenu,
MarkSet,
MarkJump,
SetAnchor,
ResetAnchor,
OpenGitDiffCursor,
EnterVisual,
ToggleSelect,
FileCreate,
FileRename,
FileDelete,
FileCopy,
FileCut,
FilePaste,
VisualCommit,
VisualSelectSiblings,
VisualSelectAll,
PreviewBack,
SearchStart,
SearchNext,
SearchPrev,
LinkFocusNext,
LinkFocusPrev,
LinkOpen,
ImageZoomIn,
ImageZoomOut,
ImageZoomReset,
PdfNextPage,
PdfPrevPage,
#[cfg(feature = "git")]
GitDiffDiscard,
#[cfg(feature = "git")]
CycleDiffLayout,
#[cfg(feature = "git")]
GitStage,
#[cfg(feature = "git")]
GitUnstage,
#[cfg(feature = "git")]
GitStageAll,
#[cfg(feature = "git")]
GitUnstageAll,
#[cfg(feature = "git")]
GitDiscard,
#[cfg(feature = "git")]
GitCommit,
#[cfg(feature = "git")]
GitWorktreeDiff,
#[cfg(feature = "git")]
GitOpenLog,
#[cfg(feature = "git")]
GitOpenGraph,
#[cfg(feature = "git")]
GitOpenBranches,
#[cfg(feature = "git")]
GitLaunchTool,
#[cfg(feature = "git")]
GitOpenSelectedDiff,
#[cfg(feature = "git")]
GitOpenDetail,
#[cfg(feature = "git")]
GitGraphSetBase,
#[cfg(feature = "git")]
GitGraphClearBase,
#[cfg(feature = "git")]
GitGraphOpenPicker,
#[cfg(feature = "git")]
GitGraphPickerToggle,
#[cfg(feature = "git")]
GitGraphPickerAll,
#[cfg(feature = "git")]
GitGraphPickerCurrentOnly,
#[cfg(feature = "git")]
GitGraphPickerMoveUp,
#[cfg(feature = "git")]
GitGraphPickerMoveDown,
#[cfg(feature = "git")]
BranchFilterStart,
#[cfg(feature = "git")]
BranchCheckout,
#[cfg(feature = "git")]
BranchCreate,
#[cfg(feature = "git")]
BranchDelete,
#[cfg(feature = "git")]
GitCopy(GitCopyKind),
#[cfg(feature = "git")]
CopyBranchName,
#[cfg(feature = "git")]
GitClose,
SortSet(SortKey),
SortToggleReverse,
SortToggleDirsFirst,
BookmarkJump,
BookmarkEdit,
BookmarkDelete,
BookmarkClose,
InfoClose,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Surface {
DialogInput,
Filter,
Search,
Mark,
#[cfg(feature = "git")]
BranchFilter,
DialogConfirmDelete,
DialogConfirmDrop,
DialogRenamePreview,
DialogConfirmQuit,
Help,
Sort,
Bookmarks,
Info,
#[cfg(feature = "git")]
GitDetail,
#[cfg(feature = "git")]
GitLog,
#[cfg(feature = "git")]
GitGraph,
#[cfg(feature = "git")]
GitGraphPicker,
#[cfg(feature = "git")]
GitBranches,
#[cfg(feature = "git")]
GitChanges,
Visual,
Tree,
PreviewText,
PreviewImage,
#[cfg(feature = "git")]
PreviewGitDiff,
}
impl Surface {
pub fn is_text_input(self) -> bool {
match self {
Surface::DialogInput | Surface::Filter | Surface::Search | Surface::Mark => true,
#[cfg(feature = "git")]
Surface::BranchFilter => true,
_ => false,
}
}
pub fn is_modal_confirm(self) -> bool {
matches!(
self,
Surface::DialogConfirmDelete
| Surface::DialogConfirmDrop
| Surface::DialogRenamePreview
| Surface::DialogConfirmQuit
)
}
pub fn allows_tabs(self) -> bool {
!self.is_text_input() && !self.is_modal_confirm()
}
#[allow(dead_code)]
pub fn inherits_copy_leader(self) -> bool {
match self {
Surface::Tree | Surface::PreviewText | Surface::PreviewImage => true,
#[cfg(feature = "git")]
Surface::PreviewGitDiff => true,
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct KeyPress {
pub code: KeyCode,
pub ctrl: bool,
}
impl KeyPress {
pub fn ch(c: char) -> KeyPress {
KeyPress {
code: KeyCode::Char(c),
ctrl: false,
}
}
pub fn ctrl_ch(c: char) -> KeyPress {
KeyPress {
code: KeyCode::Char(c),
ctrl: true,
}
}
pub fn key(code: KeyCode) -> KeyPress {
KeyPress { code, ctrl: false }
}
pub fn norm(ev: &KeyEvent) -> KeyPress {
KeyPress {
code: ev.code,
ctrl: ev.modifiers.contains(KeyModifiers::CONTROL),
}
}
pub fn parse(s: &str) -> Result<KeyPress> {
let s = s.trim();
if s.is_empty() {
bail!("empty key token");
}
let (ctrl, rest) = if let Some(r) = s.strip_prefix("ctrl-") {
(true, r)
} else if let Some(r) = s.strip_prefix("c-") {
(true, r)
} else {
(false, s)
};
if rest.is_empty() {
bail!("missing key after modifier: {s}");
}
let code = Self::parse_code(rest)?;
Ok(KeyPress { code, ctrl })
}
fn parse_code(s: &str) -> Result<KeyCode> {
let lower = s.to_ascii_lowercase();
let code = match lower.as_str() {
"space" => KeyCode::Char(' '),
"tab" => KeyCode::Tab,
"backtab" => KeyCode::BackTab,
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"backspace" => KeyCode::Backspace,
"delete" | "del" => KeyCode::Delete,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "pgup" => KeyCode::PageUp,
"pagedown" | "pgdn" | "pagedn" => KeyCode::PageDown,
_ => {
let mut chars = s.chars();
let c = chars
.next()
.ok_or_else(|| anyhow::anyhow!("empty key token"))?;
if chars.next().is_some() {
bail!("unknown key token: {s}");
}
KeyCode::Char(c)
}
};
Ok(code)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyChord {
Single(KeyPress),
Chord(KeyPress, KeyPress),
}
impl KeyChord {
pub fn parse(s: &str) -> Result<KeyChord> {
let tokens: Vec<&str> = s.split_whitespace().collect();
match tokens.len() {
1 => Ok(KeyChord::Single(KeyPress::parse(tokens[0])?)),
2 => Ok(KeyChord::Chord(
KeyPress::parse(tokens[0])?,
KeyPress::parse(tokens[1])?,
)),
n => bail!("expected 1 or 2 key tokens, got {n}: {s:?}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Binding {
Run(Action),
Leader(LeaderId),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LeaderId {
Copy,
File,
#[cfg(feature = "git")]
GitCopy,
}
#[derive(Debug, Clone)]
pub struct LeaderItem {
pub key: KeyPress,
pub action: Action,
pub label: Msg,
}
#[derive(Debug, Clone)]
pub struct LeaderMenu {
#[allow(dead_code)]
pub id: LeaderId,
pub title: Msg,
pub items: Vec<LeaderItem>,
}
impl LeaderMenu {
fn find(&self, kp: KeyPress) -> Option<Action> {
self.items
.iter()
.find(|it| it.key == kp)
.map(|it| it.action)
}
fn set(&mut self, kp: KeyPress, action: Action) {
let label = leader_label(action);
self.items.retain(|it| it.key != kp);
self.items.push(LeaderItem {
key: kp,
action,
label,
});
}
fn remove(&mut self, kp: KeyPress) {
self.items.retain(|it| it.key != kp);
}
}
pub type ContextMap = HashMap<KeyPress, Binding>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum KeyScheme {
#[default]
Vim,
Less,
}
pub fn scheme_from_str(s: &str) -> KeyScheme {
if s.eq_ignore_ascii_case("less") {
KeyScheme::Less
} else {
KeyScheme::Vim
}
}
#[derive(Debug, Clone, Default)]
pub struct KeysFileConfig {
pub surfaces: HashMap<String, HashMap<String, String>>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct KeyConflict {
pub surface: Surface,
pub key: KeyPress,
pub kept: String,
pub dropped: String,
pub reason: ConflictKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictKind {
PrefixVsSingle,
GlobalShadow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
Action(Action),
EnterLeader(LeaderId),
Unbound,
}
#[derive(Debug, Clone)]
pub struct KeyMap {
pub per_surface: HashMap<Surface, ContextMap>,
pub global: ContextMap,
pub leaders: HashMap<LeaderId, LeaderMenu>,
pub conflicts: Vec<KeyConflict>,
pub warnings: Vec<String>,
}
impl KeyMap {
pub fn defaults(scheme: KeyScheme) -> KeyMap {
let nav = |m: Motion| Binding::Run(Action::Navigate(m));
let run = |a: Action| Binding::Run(a);
let mut per_surface: HashMap<Surface, ContextMap> = HashMap::new();
let mut tree: ContextMap = HashMap::new();
tree.insert(KeyPress::ch('j'), nav(Motion::Down));
tree.insert(KeyPress::ch('k'), nav(Motion::Up));
tree.insert(KeyPress::ch('g'), nav(Motion::Top));
tree.insert(KeyPress::ch('G'), nav(Motion::Bottom));
tree.insert(KeyPress::ctrl_ch('d'), nav(Motion::HalfDown));
tree.insert(KeyPress::ctrl_ch('u'), nav(Motion::HalfUp));
tree.insert(KeyPress::ctrl_ch('f'), nav(Motion::PageDown));
tree.insert(KeyPress::ctrl_ch('b'), nav(Motion::PageUp));
tree.insert(KeyPress::key(KeyCode::PageDown), nav(Motion::PageDown));
tree.insert(KeyPress::key(KeyCode::PageUp), nav(Motion::PageUp));
tree.insert(KeyPress::ch('h'), run(Action::TreeLeave));
tree.insert(KeyPress::ch('l'), run(Action::TreeDescend));
tree.insert(KeyPress::ch('q'), run(Action::Quit));
tree.insert(KeyPress::ch('/'), run(Action::FilterStart));
tree.insert(KeyPress::ch('.'), run(Action::ToggleHidden));
tree.insert(KeyPress::ch('r'), run(Action::Refresh));
tree.insert(KeyPress::ch('s'), run(Action::OpenSortMenu));
tree.insert(KeyPress::ch('i'), run(Action::ToggleInfo));
tree.insert(KeyPress::ch('e'), run(Action::RequestEdit));
tree.insert(KeyPress::ch('p'), run(Action::CyclePathStyle));
tree.insert(KeyPress::ch('m'), run(Action::MarkSet));
tree.insert(KeyPress::ch('\''), run(Action::MarkJump));
tree.insert(KeyPress::ch('d'), run(Action::OpenGitDiffCursor));
tree.insert(KeyPress::ch('o'), run(Action::OpenGitView));
tree.insert(KeyPress::ch('a'), run(Action::SetAnchor));
tree.insert(KeyPress::ch('A'), run(Action::ResetAnchor));
tree.insert(KeyPress::ch('v'), run(Action::EnterVisual));
tree.insert(KeyPress::ch('V'), run(Action::ToggleSelect));
tree.insert(KeyPress::ch('y'), Binding::Leader(LeaderId::Copy));
tree.insert(KeyPress::ch(' '), Binding::Leader(LeaderId::File));
per_surface.insert(Surface::Tree, tree);
let mut visual: ContextMap = HashMap::new();
visual.insert(KeyPress::ch('j'), nav(Motion::Down));
visual.insert(KeyPress::ch('k'), nav(Motion::Up));
visual.insert(KeyPress::ch('g'), nav(Motion::Top));
visual.insert(KeyPress::ch('G'), nav(Motion::Bottom));
visual.insert(KeyPress::ctrl_ch('d'), nav(Motion::HalfDown));
visual.insert(KeyPress::ctrl_ch('u'), nav(Motion::HalfUp));
visual.insert(KeyPress::key(KeyCode::PageDown), nav(Motion::PageDown));
visual.insert(KeyPress::key(KeyCode::PageUp), nav(Motion::PageUp));
visual.insert(KeyPress::ch('v'), run(Action::VisualCommit));
visual.insert(KeyPress::ch('a'), run(Action::VisualSelectSiblings));
visual.insert(KeyPress::ch('A'), run(Action::VisualSelectAll));
visual.insert(KeyPress::ch(' '), Binding::Leader(LeaderId::File));
visual.insert(KeyPress::ch('q'), run(Action::Quit)); per_surface.insert(Surface::Visual, visual);
let mut ptext: ContextMap = HashMap::new();
ptext.insert(KeyPress::ch('q'), run(Action::PreviewBack));
ptext.insert(KeyPress::ch('/'), run(Action::SearchStart));
ptext.insert(KeyPress::ch('n'), run(Action::SearchNext));
ptext.insert(KeyPress::ch('N'), run(Action::SearchPrev));
ptext.insert(KeyPress::ch('j'), nav(Motion::Down));
ptext.insert(KeyPress::ch('k'), nav(Motion::Up));
ptext.insert(KeyPress::ch('g'), nav(Motion::Top));
ptext.insert(KeyPress::ch('G'), nav(Motion::Bottom));
ptext.insert(KeyPress::ch('l'), nav(Motion::Right));
ptext.insert(KeyPress::ch('h'), nav(Motion::Left));
ptext.insert(KeyPress::ch('0'), nav(Motion::LineHome));
ptext.insert(KeyPress::ch('$'), nav(Motion::LineEnd));
ptext.insert(KeyPress::ch('p'), run(Action::CyclePathStyle));
ptext.insert(KeyPress::ch('e'), run(Action::RequestEdit));
ptext.insert(KeyPress::key(KeyCode::PageDown), nav(Motion::PageDown));
ptext.insert(KeyPress::key(KeyCode::PageUp), nav(Motion::PageUp));
apply_scheme_paging(&mut ptext, scheme);
ptext.insert(KeyPress::ch('y'), Binding::Leader(LeaderId::Copy));
per_surface.insert(Surface::PreviewText, ptext);
let mut pimg: ContextMap = HashMap::new();
pimg.insert(KeyPress::ch('q'), run(Action::PreviewBack));
pimg.insert(KeyPress::ch('+'), run(Action::ImageZoomIn));
pimg.insert(KeyPress::ch('-'), run(Action::ImageZoomOut));
pimg.insert(KeyPress::ch('0'), run(Action::ImageZoomReset));
pimg.insert(KeyPress::ch('='), run(Action::ImageZoomReset));
pimg.insert(KeyPress::ch('h'), nav(Motion::Left));
pimg.insert(KeyPress::ch('l'), nav(Motion::Right));
pimg.insert(KeyPress::ch('k'), nav(Motion::Up));
pimg.insert(KeyPress::ch('j'), nav(Motion::Down));
pimg.insert(KeyPress::ch('J'), run(Action::PdfNextPage));
pimg.insert(KeyPress::ch('K'), run(Action::PdfPrevPage));
pimg.insert(KeyPress::key(KeyCode::PageDown), run(Action::PdfNextPage));
pimg.insert(KeyPress::key(KeyCode::PageUp), run(Action::PdfPrevPage));
pimg.insert(KeyPress::ch('p'), run(Action::CyclePathStyle));
pimg.insert(KeyPress::ch('e'), run(Action::RequestEdit));
pimg.insert(KeyPress::ch('y'), Binding::Leader(LeaderId::Copy));
per_surface.insert(Surface::PreviewImage, pimg);
#[cfg(feature = "git")]
{
let mut pgit: ContextMap = HashMap::new();
pgit.insert(KeyPress::ch('q'), run(Action::PreviewBack));
pgit.insert(KeyPress::ch('x'), run(Action::GitDiffDiscard));
pgit.insert(KeyPress::ch('s'), run(Action::CycleDiffLayout));
pgit.insert(KeyPress::ch('j'), nav(Motion::Down));
pgit.insert(KeyPress::ch('k'), nav(Motion::Up));
pgit.insert(KeyPress::ch('l'), nav(Motion::Right));
pgit.insert(KeyPress::ch('h'), nav(Motion::Left));
pgit.insert(KeyPress::ch('0'), nav(Motion::LineHome));
pgit.insert(KeyPress::ch('$'), nav(Motion::LineEnd));
pgit.insert(KeyPress::ch('g'), nav(Motion::Top));
pgit.insert(KeyPress::ch('G'), nav(Motion::Bottom));
pgit.insert(KeyPress::key(KeyCode::PageDown), nav(Motion::PageDown));
pgit.insert(KeyPress::key(KeyCode::PageUp), nav(Motion::PageUp));
apply_scheme_paging(&mut pgit, scheme);
pgit.insert(KeyPress::ch('y'), Binding::Leader(LeaderId::Copy));
per_surface.insert(Surface::PreviewGitDiff, pgit);
let mut gchg: ContextMap = HashMap::new();
gchg.insert(KeyPress::ch('j'), nav(Motion::Down));
gchg.insert(KeyPress::ch('k'), nav(Motion::Up));
gchg.insert(KeyPress::ch('s'), run(Action::GitStage));
gchg.insert(KeyPress::ch('u'), run(Action::GitUnstage));
gchg.insert(KeyPress::ch('S'), run(Action::GitStageAll));
gchg.insert(KeyPress::ch('U'), run(Action::GitUnstageAll));
gchg.insert(KeyPress::ch('x'), run(Action::GitDiscard));
gchg.insert(KeyPress::ch('c'), run(Action::GitCommit));
gchg.insert(KeyPress::ch('d'), run(Action::GitWorktreeDiff));
gchg.insert(KeyPress::ch('b'), run(Action::GitOpenBranches));
gchg.insert(KeyPress::ch('l'), run(Action::GitOpenLog));
gchg.insert(KeyPress::ch('g'), run(Action::GitOpenGraph));
gchg.insert(KeyPress::ch('!'), run(Action::GitLaunchTool));
gchg.insert(KeyPress::ch('q'), run(Action::GitClose));
gchg.insert(KeyPress::ch('y'), Binding::Leader(LeaderId::Copy));
per_surface.insert(Surface::GitChanges, gchg);
let mut glog: ContextMap = HashMap::new();
glog.insert(KeyPress::ch('j'), nav(Motion::Down));
glog.insert(KeyPress::ch('k'), nav(Motion::Up));
glog.insert(KeyPress::ch('g'), nav(Motion::Top));
glog.insert(KeyPress::ch('G'), nav(Motion::Bottom));
glog.insert(KeyPress::ch('l'), run(Action::GitOpenDetail));
glog.insert(KeyPress::ch('q'), run(Action::GitClose));
glog.insert(KeyPress::ch('y'), Binding::Leader(LeaderId::GitCopy));
let mut ggraph = glog.clone();
ggraph.insert(KeyPress::ch('s'), run(Action::GitGraphSetBase));
ggraph.insert(KeyPress::ch('x'), run(Action::GitGraphClearBase));
ggraph.insert(KeyPress::ch('b'), run(Action::GitGraphOpenPicker));
per_surface.insert(Surface::GitGraph, ggraph);
per_surface.insert(Surface::GitLog, glog);
let mut gpick: ContextMap = HashMap::new();
gpick.insert(KeyPress::ch('j'), nav(Motion::Down));
gpick.insert(KeyPress::ch('k'), nav(Motion::Up));
gpick.insert(KeyPress::ch('g'), nav(Motion::Top));
gpick.insert(KeyPress::ch('G'), nav(Motion::Bottom));
gpick.insert(KeyPress::ch(' '), run(Action::GitGraphPickerToggle));
gpick.insert(KeyPress::ch('a'), run(Action::GitGraphPickerAll));
gpick.insert(KeyPress::ch('n'), run(Action::GitGraphPickerCurrentOnly));
gpick.insert(KeyPress::ch('K'), run(Action::GitGraphPickerMoveUp));
gpick.insert(KeyPress::ch('J'), run(Action::GitGraphPickerMoveDown));
gpick.insert(KeyPress::ch('q'), run(Action::GitClose));
per_surface.insert(Surface::GitGraphPicker, gpick);
let mut gbr: ContextMap = HashMap::new();
gbr.insert(KeyPress::ch('j'), nav(Motion::Down));
gbr.insert(KeyPress::ch('k'), nav(Motion::Up));
gbr.insert(KeyPress::ch('g'), nav(Motion::Top));
gbr.insert(KeyPress::ch('G'), nav(Motion::Bottom));
gbr.insert(KeyPress::ch('/'), run(Action::BranchFilterStart));
gbr.insert(KeyPress::ch('l'), run(Action::BranchCheckout));
gbr.insert(KeyPress::ch('n'), run(Action::BranchCreate));
gbr.insert(KeyPress::ch('d'), run(Action::BranchDelete));
gbr.insert(KeyPress::ch('q'), run(Action::GitClose));
gbr.insert(KeyPress::ch('y'), run(Action::CopyBranchName));
per_surface.insert(Surface::GitBranches, gbr);
let mut gdet: ContextMap = HashMap::new();
gdet.insert(KeyPress::ch('j'), nav(Motion::Down));
gdet.insert(KeyPress::ch('k'), nav(Motion::Up));
gdet.insert(KeyPress::ch('l'), nav(Motion::Right));
gdet.insert(KeyPress::ch('h'), nav(Motion::Left));
gdet.insert(KeyPress::ch('0'), nav(Motion::LineHome));
gdet.insert(KeyPress::ch('$'), nav(Motion::LineEnd));
gdet.insert(KeyPress::ch('g'), nav(Motion::Top));
gdet.insert(KeyPress::ch('G'), nav(Motion::Bottom));
gdet.insert(KeyPress::key(KeyCode::PageDown), nav(Motion::PageDown));
gdet.insert(KeyPress::key(KeyCode::PageUp), nav(Motion::PageUp));
gdet.insert(KeyPress::ch('s'), run(Action::CycleDiffLayout));
gdet.insert(KeyPress::ch('q'), run(Action::GitClose));
gdet.insert(KeyPress::ch('y'), Binding::Leader(LeaderId::GitCopy));
apply_scheme_paging(&mut gdet, scheme);
per_surface.insert(Surface::GitDetail, gdet);
}
let mut sort: ContextMap = HashMap::new();
sort.insert(KeyPress::ch('n'), run(Action::SortSet(SortKey::Name)));
sort.insert(KeyPress::ch('s'), run(Action::SortSet(SortKey::Size)));
sort.insert(KeyPress::ch('m'), run(Action::SortSet(SortKey::Modified)));
sort.insert(KeyPress::ch('e'), run(Action::SortSet(SortKey::Ext)));
sort.insert(KeyPress::ch('r'), run(Action::SortToggleReverse));
sort.insert(KeyPress::ch('.'), run(Action::SortToggleDirsFirst));
per_surface.insert(Surface::Sort, sort);
let mut bm: ContextMap = HashMap::new();
bm.insert(KeyPress::ch('j'), nav(Motion::Down));
bm.insert(KeyPress::ch('k'), nav(Motion::Up));
bm.insert(KeyPress::ch('e'), run(Action::BookmarkEdit));
bm.insert(KeyPress::ch('d'), run(Action::BookmarkDelete));
bm.insert(KeyPress::ch('q'), run(Action::BookmarkClose));
per_surface.insert(Surface::Bookmarks, bm);
let mut info: ContextMap = HashMap::new();
info.insert(KeyPress::ch('i'), run(Action::InfoClose));
info.insert(KeyPress::ch('q'), run(Action::InfoClose));
per_surface.insert(Surface::Info, info);
let mut help: ContextMap = HashMap::new();
help.insert(KeyPress::ch('j'), nav(Motion::Down));
help.insert(KeyPress::ch('k'), nav(Motion::Up));
help.insert(KeyPress::ch('g'), nav(Motion::Top));
help.insert(KeyPress::ch('G'), nav(Motion::Bottom));
help.insert(KeyPress::ch('q'), run(Action::ToggleHelp));
per_surface.insert(Surface::Help, help);
let mut global: ContextMap = HashMap::new();
global.insert(KeyPress::ch('Q'), run(Action::Quit));
global.insert(KeyPress::ch('t'), run(Action::TabNew));
global.insert(KeyPress::ch('w'), run(Action::TabClose));
global.insert(KeyPress::ch('['), run(Action::TabPrev));
global.insert(KeyPress::ch(']'), run(Action::TabNext));
for i in 1..=9u8 {
let c = char::from(b'0' + i);
global.insert(KeyPress::ch(c), run(Action::TabGoto(i - 1)));
}
global.insert(KeyPress::ch('?'), run(Action::ToggleHelp));
let mut leaders: HashMap<LeaderId, LeaderMenu> = HashMap::new();
leaders.insert(LeaderId::Copy, copy_leader_default());
leaders.insert(LeaderId::File, file_leader_default());
#[cfg(feature = "git")]
leaders.insert(LeaderId::GitCopy, git_copy_leader_default());
KeyMap {
per_surface,
global,
leaders,
conflicts: Vec::new(),
warnings: Vec::new(),
}
}
pub fn from_config(scheme: KeyScheme, cfg: &KeysFileConfig) -> KeyMap {
let mut map = KeyMap::defaults(scheme);
let defaults = KeyMap::defaults(scheme); let mut warnings: Vec<String> = Vec::new();
for (sfc_name, table) in &cfg.surfaces {
let target = match key_target_from_name(sfc_name) {
Some(t) => t,
None => {
warnings.push(format!("unknown key surface: {sfc_name}"));
continue;
}
};
for (chord_str, action_str) in table {
map.apply_one(target, chord_str, action_str, &mut warnings);
}
}
map.validate(&defaults);
map.warnings = warnings;
map
}
fn apply_one(
&mut self,
target: KeyTarget,
chord_str: &str,
action_str: &str,
warnings: &mut Vec<String>,
) {
let chord = match KeyChord::parse(chord_str) {
Ok(c) => c,
Err(e) => {
warnings.push(format!("invalid key {chord_str:?}: {e}"));
return;
}
};
let is_noop = action_str == "noop" || action_str == "disabled";
if action_str == "tab_goto" {
warnings.push("tab_goto is fixed to digit keys and cannot be rebound".into());
return;
}
let action = if is_noop {
None
} else {
match action_from_str(action_str) {
Some(a) => Some(a),
None => {
warnings.push(format!("unknown action: {action_str}"));
return;
}
}
};
match chord {
KeyChord::Single(kp) => {
if is_fixed_key(kp) {
warnings.push(format!("cannot rebind fixed key: {chord_str:?}"));
return;
}
let cmap = self.context_mut(target);
match action {
Some(a) => {
cmap.insert(kp, Binding::Run(a));
}
None => {
cmap.remove(&kp);
}
}
}
KeyChord::Chord(pre, suf) => {
let lead = match leader_for_prefix(pre) {
Some(l) => l,
None => {
warnings.push(format!(
"unsupported chord prefix (only `space`/`y` leaders are supported): {chord_str:?}"
));
return;
}
};
let menu = match self.leaders.get_mut(&lead) {
Some(m) => m,
None => return,
};
match action {
Some(a) => menu.set(suf, a),
None => menu.remove(suf),
}
}
}
}
fn context_mut(&mut self, target: KeyTarget) -> &mut ContextMap {
match target {
KeyTarget::Global => &mut self.global,
KeyTarget::Surface(s) => self.per_surface.entry(s).or_default(),
}
}
fn validate(&mut self, defaults: &KeyMap) {
let mut conflicts: Vec<KeyConflict> = Vec::new();
let global_keys: Vec<KeyPress> = self.global.keys().copied().collect();
let surfaces: Vec<Surface> = self.per_surface.keys().copied().collect();
for sfc in surfaces {
let leader_keys: Vec<(KeyPress, LeaderId)> = defaults
.per_surface
.get(&sfc)
.map(|m| {
m.iter()
.filter_map(|(k, b)| match b {
Binding::Leader(id) => Some((*k, *id)),
_ => None,
})
.collect()
})
.unwrap_or_default();
for (k, id) in leader_keys {
let cur = self.per_surface.get(&sfc).and_then(|m| m.get(&k)).cloned();
if let Some(Binding::Run(a)) = cur {
conflicts.push(KeyConflict {
surface: sfc,
key: k,
kept: format!("leader:{}", leader_name(id)),
dropped: action_name(a),
reason: ConflictKind::PrefixVsSingle,
});
self.per_surface
.get_mut(&sfc)
.expect("surface present")
.insert(k, Binding::Leader(id));
}
}
if sfc.allows_tabs() {
let shadows: Vec<(KeyPress, String, String)> = {
let cmap = match self.per_surface.get(&sfc) {
Some(m) => m,
None => continue,
};
global_keys
.iter()
.filter_map(|gk| {
let local = cmap.get(gk)?;
let global = self.global.get(gk);
if Some(local) != global {
Some((
*gk,
global.map(binding_name).unwrap_or_default(),
binding_name(local),
))
} else {
None
}
})
.collect()
};
for (gk, kept, dropped) in shadows {
conflicts.push(KeyConflict {
surface: sfc,
key: gk,
kept,
dropped,
reason: ConflictKind::GlobalShadow,
});
self.per_surface
.get_mut(&sfc)
.expect("surface present")
.remove(&gk);
}
}
}
self.conflicts = conflicts;
}
pub fn resolve(&self, sfc: Surface, pending: Option<LeaderId>, kp: KeyPress) -> Resolution {
if let Some(id) = pending {
return match self.leaders.get(&id).and_then(|m| m.find(kp)) {
Some(a) => Resolution::Action(a),
None => Resolution::Unbound,
};
}
if let Some(b) = self.per_surface.get(&sfc).and_then(|m| m.get(&kp)) {
return binding_to_resolution(b);
}
if sfc.allows_tabs() {
if let Some(b) = self.global.get(&kp) {
return binding_to_resolution(b);
}
}
Resolution::Unbound
}
}
fn binding_to_resolution(b: &Binding) -> Resolution {
match b {
Binding::Run(a) => Resolution::Action(*a),
Binding::Leader(id) => Resolution::EnterLeader(*id),
}
}
#[derive(Debug, Clone, Copy)]
enum KeyTarget {
Global,
Surface(Surface),
}
fn key_target_from_name(name: &str) -> Option<KeyTarget> {
let s = match name {
"global" => return Some(KeyTarget::Global),
"tree" => Surface::Tree,
"tree_visual" => Surface::Visual,
"preview_text" => Surface::PreviewText,
"preview_image" => Surface::PreviewImage,
"sort" => Surface::Sort,
"bookmarks" => Surface::Bookmarks,
"info" => Surface::Info,
"help" => Surface::Help,
#[cfg(feature = "git")]
"preview_git_diff" => Surface::PreviewGitDiff,
#[cfg(feature = "git")]
"git_changes" => Surface::GitChanges,
#[cfg(feature = "git")]
"git_log" => Surface::GitLog,
#[cfg(feature = "git")]
"git_graph" => Surface::GitGraph,
#[cfg(feature = "git")]
"git_branches" => Surface::GitBranches,
#[cfg(feature = "git")]
"git_detail" => Surface::GitDetail,
_ => return None,
};
Some(KeyTarget::Surface(s))
}
fn leader_for_prefix(kp: KeyPress) -> Option<LeaderId> {
if kp == KeyPress::ch(' ') {
Some(LeaderId::File)
} else if kp == KeyPress::ch('y') {
Some(LeaderId::Copy)
} else {
None
}
}
fn leader_name(id: LeaderId) -> &'static str {
match id {
LeaderId::Copy => "copy",
LeaderId::File => "file",
#[cfg(feature = "git")]
LeaderId::GitCopy => "git_copy",
}
}
fn is_fixed_key(kp: KeyPress) -> bool {
matches!(
kp.code,
KeyCode::Esc
| KeyCode::Enter
| KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Tab
| KeyCode::BackTab
| KeyCode::Home
| KeyCode::End
| KeyCode::Up
| KeyCode::Down
| KeyCode::Left
| KeyCode::Right
)
}
fn apply_scheme_paging(m: &mut ContextMap, scheme: KeyScheme) {
match scheme {
KeyScheme::Vim => {
m.insert(
KeyPress::ctrl_ch('f'),
Binding::Run(Action::Navigate(Motion::PageDown)),
);
m.insert(
KeyPress::ctrl_ch('b'),
Binding::Run(Action::Navigate(Motion::PageUp)),
);
m.insert(
KeyPress::ctrl_ch('d'),
Binding::Run(Action::Navigate(Motion::HalfDown)),
);
m.insert(
KeyPress::ctrl_ch('u'),
Binding::Run(Action::Navigate(Motion::HalfUp)),
);
}
KeyScheme::Less => {
m.insert(
KeyPress::ch('f'),
Binding::Run(Action::Navigate(Motion::PageDown)),
);
m.insert(
KeyPress::ch(' '),
Binding::Run(Action::Navigate(Motion::PageDown)),
);
m.insert(
KeyPress::ch('b'),
Binding::Run(Action::Navigate(Motion::PageUp)),
);
m.insert(
KeyPress::ch('d'),
Binding::Run(Action::Navigate(Motion::HalfDown)),
);
m.insert(
KeyPress::ch('u'),
Binding::Run(Action::Navigate(Motion::HalfUp)),
);
}
}
}
fn copy_leader_default() -> LeaderMenu {
LeaderMenu {
id: LeaderId::Copy,
title: Msg::WkCopyPathTitle,
items: vec![
LeaderItem {
key: KeyPress::ch('n'),
action: Action::CopyPath(CopyKind::Name),
label: Msg::WkName,
},
LeaderItem {
key: KeyPress::ch('r'),
action: Action::CopyPath(CopyKind::Relative),
label: Msg::WkRelative,
},
LeaderItem {
key: KeyPress::ch('f'),
action: Action::CopyPath(CopyKind::Full),
label: Msg::WkFull,
},
LeaderItem {
key: KeyPress::ch('p'),
action: Action::CopyPath(CopyKind::Parent),
label: Msg::WkParent,
},
],
}
}
#[cfg(feature = "git")]
fn git_copy_leader_default() -> LeaderMenu {
LeaderMenu {
id: LeaderId::GitCopy,
title: Msg::WkGitCopyTitle,
items: vec![
LeaderItem {
key: KeyPress::ch('s'),
action: Action::GitCopy(GitCopyKind::ShortHash),
label: Msg::WkShortHash,
},
LeaderItem {
key: KeyPress::ch('h'),
action: Action::GitCopy(GitCopyKind::FullHash),
label: Msg::WkFullHash,
},
LeaderItem {
key: KeyPress::ch('t'),
action: Action::GitCopy(GitCopyKind::Subject),
label: Msg::WkSubject,
},
LeaderItem {
key: KeyPress::ch('m'),
action: Action::GitCopy(GitCopyKind::Message),
label: Msg::WkMessage,
},
LeaderItem {
key: KeyPress::ch('a'),
action: Action::GitCopy(GitCopyKind::Author),
label: Msg::WkAuthor,
},
LeaderItem {
key: KeyPress::ch('d'),
action: Action::GitCopy(GitCopyKind::Date),
label: Msg::WkDate,
},
],
}
}
fn file_leader_default() -> LeaderMenu {
LeaderMenu {
id: LeaderId::File,
title: Msg::TreeFile,
items: vec![
LeaderItem {
key: KeyPress::ch('n'),
action: Action::FileCreate,
label: Msg::WkCreate,
},
LeaderItem {
key: KeyPress::ch('r'),
action: Action::FileRename,
label: Msg::WkRename,
},
LeaderItem {
key: KeyPress::ch('d'),
action: Action::FileDelete,
label: Msg::WkDelete,
},
LeaderItem {
key: KeyPress::ch('c'),
action: Action::FileCopy,
label: Msg::CopyHint,
},
LeaderItem {
key: KeyPress::ch('x'),
action: Action::FileCut,
label: Msg::CutHint,
},
LeaderItem {
key: KeyPress::ch('p'),
action: Action::FilePaste,
label: Msg::WkPaste,
},
],
}
}
fn leader_label(a: Action) -> Msg {
match a {
Action::CopyPath(CopyKind::Name) => Msg::WkName,
Action::CopyPath(CopyKind::Relative) => Msg::WkRelative,
Action::CopyPath(CopyKind::Full) => Msg::WkFull,
Action::CopyPath(CopyKind::Parent) => Msg::WkParent,
Action::FileCreate => Msg::WkCreate,
Action::FileRename => Msg::WkRename,
Action::FileDelete => Msg::WkDelete,
Action::FileCopy => Msg::CopyHint,
Action::FileCut => Msg::CutHint,
Action::FilePaste => Msg::WkPaste,
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::ShortHash) => Msg::WkShortHash,
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::FullHash) => Msg::WkFullHash,
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::Subject) => Msg::WkSubject,
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::Message) => Msg::WkMessage,
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::Author) => Msg::WkAuthor,
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::Date) => Msg::WkDate,
_ => Msg::Empty,
}
}
fn motion_name(m: Motion) -> &'static str {
match m {
Motion::Up => "up",
Motion::Down => "down",
Motion::Top => "top",
Motion::Bottom => "bottom",
Motion::PageUp => "page_up",
Motion::PageDown => "page_down",
Motion::HalfUp => "half_up",
Motion::HalfDown => "half_down",
Motion::Left => "left",
Motion::Right => "right",
Motion::LineHome => "line_home",
Motion::LineEnd => "line_end",
}
}
fn motion_from_str(s: &str) -> Option<Motion> {
Some(match s {
"up" => Motion::Up,
"down" => Motion::Down,
"top" => Motion::Top,
"bottom" => Motion::Bottom,
"page_up" => Motion::PageUp,
"page_down" => Motion::PageDown,
"half_up" => Motion::HalfUp,
"half_down" => Motion::HalfDown,
"left" => Motion::Left,
"right" => Motion::Right,
"line_home" => Motion::LineHome,
"line_end" => Motion::LineEnd,
_ => return None,
})
}
pub fn action_from_str(s: &str) -> Option<Action> {
if let Some(m) = s.strip_prefix("navigate:") {
return motion_from_str(m).map(Action::Navigate);
}
Some(match s {
"noop" | "disabled" => Action::Noop,
"tab_new" => Action::TabNew,
"tab_close" => Action::TabClose,
"tab_prev" => Action::TabPrev,
"tab_next" => Action::TabNext,
"toggle_help" => Action::ToggleHelp,
"copy_name" => Action::CopyPath(CopyKind::Name),
"copy_relative" => Action::CopyPath(CopyKind::Relative),
"copy_full" => Action::CopyPath(CopyKind::Full),
"copy_parent" => Action::CopyPath(CopyKind::Parent),
"quit" => Action::Quit,
"filter_start" => Action::FilterStart,
"tree_descend" => Action::TreeDescend,
"tree_activate" => Action::TreeActivate,
"tree_leave" => Action::TreeLeave,
"toggle_hidden" => Action::ToggleHidden,
"toggle_info" => Action::ToggleInfo,
"request_edit" => Action::RequestEdit,
"open_git_view" => Action::OpenGitView,
"refresh" => Action::Refresh,
"cycle_path_style" => Action::CyclePathStyle,
"open_sort_menu" => Action::OpenSortMenu,
"mark_set" => Action::MarkSet,
"mark_jump" => Action::MarkJump,
"set_anchor" => Action::SetAnchor,
"reset_anchor" => Action::ResetAnchor,
"open_git_diff_cursor" => Action::OpenGitDiffCursor,
"enter_visual" => Action::EnterVisual,
"toggle_select" => Action::ToggleSelect,
"file_create" => Action::FileCreate,
"file_rename" => Action::FileRename,
"file_delete" => Action::FileDelete,
"file_copy" => Action::FileCopy,
"file_cut" => Action::FileCut,
"file_paste" => Action::FilePaste,
"visual_commit" => Action::VisualCommit,
"visual_select_siblings" => Action::VisualSelectSiblings,
"visual_select_all" => Action::VisualSelectAll,
"preview_back" => Action::PreviewBack,
"search_start" => Action::SearchStart,
"search_next" => Action::SearchNext,
"search_prev" => Action::SearchPrev,
"link_focus_next" => Action::LinkFocusNext,
"link_focus_prev" => Action::LinkFocusPrev,
"link_open" => Action::LinkOpen,
"image_zoom_in" => Action::ImageZoomIn,
"image_zoom_out" => Action::ImageZoomOut,
"image_zoom_reset" => Action::ImageZoomReset,
"pdf_next_page" => Action::PdfNextPage,
"pdf_prev_page" => Action::PdfPrevPage,
"sort_name" => Action::SortSet(SortKey::Name),
"sort_size" => Action::SortSet(SortKey::Size),
"sort_modified" => Action::SortSet(SortKey::Modified),
"sort_ext" => Action::SortSet(SortKey::Ext),
"sort_toggle_reverse" => Action::SortToggleReverse,
"sort_toggle_dirs_first" => Action::SortToggleDirsFirst,
"bookmark_jump" => Action::BookmarkJump,
"bookmark_edit" => Action::BookmarkEdit,
"bookmark_delete" => Action::BookmarkDelete,
"bookmark_close" => Action::BookmarkClose,
"info_close" => Action::InfoClose,
#[cfg(feature = "git")]
"git_diff_discard" => Action::GitDiffDiscard,
#[cfg(feature = "git")]
"cycle_diff_layout" => Action::CycleDiffLayout,
#[cfg(feature = "git")]
"git_stage" => Action::GitStage,
#[cfg(feature = "git")]
"git_unstage" => Action::GitUnstage,
#[cfg(feature = "git")]
"git_stage_all" => Action::GitStageAll,
#[cfg(feature = "git")]
"git_unstage_all" => Action::GitUnstageAll,
#[cfg(feature = "git")]
"git_discard" => Action::GitDiscard,
#[cfg(feature = "git")]
"git_commit" => Action::GitCommit,
#[cfg(feature = "git")]
"git_worktree_diff" => Action::GitWorktreeDiff,
#[cfg(feature = "git")]
"git_open_log" => Action::GitOpenLog,
#[cfg(feature = "git")]
"git_open_graph" => Action::GitOpenGraph,
#[cfg(feature = "git")]
"git_open_branches" => Action::GitOpenBranches,
#[cfg(feature = "git")]
"git_launch_tool" => Action::GitLaunchTool,
#[cfg(feature = "git")]
"git_open_selected_diff" => Action::GitOpenSelectedDiff,
#[cfg(feature = "git")]
"git_open_detail" => Action::GitOpenDetail,
#[cfg(feature = "git")]
"branch_filter_start" => Action::BranchFilterStart,
#[cfg(feature = "git")]
"branch_checkout" => Action::BranchCheckout,
#[cfg(feature = "git")]
"branch_create" => Action::BranchCreate,
#[cfg(feature = "git")]
"branch_delete" => Action::BranchDelete,
#[cfg(feature = "git")]
"git_copy_short_hash" => Action::GitCopy(GitCopyKind::ShortHash),
#[cfg(feature = "git")]
"git_copy_full_hash" => Action::GitCopy(GitCopyKind::FullHash),
#[cfg(feature = "git")]
"git_copy_subject" => Action::GitCopy(GitCopyKind::Subject),
#[cfg(feature = "git")]
"git_copy_message" => Action::GitCopy(GitCopyKind::Message),
#[cfg(feature = "git")]
"git_copy_author" => Action::GitCopy(GitCopyKind::Author),
#[cfg(feature = "git")]
"git_copy_date" => Action::GitCopy(GitCopyKind::Date),
#[cfg(feature = "git")]
"copy_branch_name" => Action::CopyBranchName,
#[cfg(feature = "git")]
"git_close" => Action::GitClose,
_ => return None,
})
}
pub fn action_name(a: Action) -> String {
let s: &str = match a {
Action::Noop => "noop",
Action::Navigate(m) => return format!("navigate:{}", motion_name(m)),
Action::TabNew => "tab_new",
Action::TabClose => "tab_close",
Action::TabPrev => "tab_prev",
Action::TabNext => "tab_next",
Action::TabGoto(_) => "tab_goto",
Action::ToggleHelp => "toggle_help",
Action::CopyPath(CopyKind::Name) => "copy_name",
Action::CopyPath(CopyKind::Relative) => "copy_relative",
Action::CopyPath(CopyKind::Full) => "copy_full",
Action::CopyPath(CopyKind::Parent) => "copy_parent",
Action::Quit => "quit",
Action::FilterStart => "filter_start",
Action::TreeDescend => "tree_descend",
Action::TreeActivate => "tree_activate",
Action::TreeLeave => "tree_leave",
Action::ToggleHidden => "toggle_hidden",
Action::ToggleInfo => "toggle_info",
Action::RequestEdit => "request_edit",
Action::OpenGitView => "open_git_view",
Action::Refresh => "refresh",
Action::CyclePathStyle => "cycle_path_style",
Action::OpenSortMenu => "open_sort_menu",
Action::MarkSet => "mark_set",
Action::MarkJump => "mark_jump",
Action::SetAnchor => "set_anchor",
Action::ResetAnchor => "reset_anchor",
Action::OpenGitDiffCursor => "open_git_diff_cursor",
Action::EnterVisual => "enter_visual",
Action::ToggleSelect => "toggle_select",
Action::FileCreate => "file_create",
Action::FileRename => "file_rename",
Action::FileDelete => "file_delete",
Action::FileCopy => "file_copy",
Action::FileCut => "file_cut",
Action::FilePaste => "file_paste",
Action::VisualCommit => "visual_commit",
Action::VisualSelectSiblings => "visual_select_siblings",
Action::VisualSelectAll => "visual_select_all",
Action::PreviewBack => "preview_back",
Action::SearchStart => "search_start",
Action::SearchNext => "search_next",
Action::SearchPrev => "search_prev",
Action::LinkFocusNext => "link_focus_next",
Action::LinkFocusPrev => "link_focus_prev",
Action::LinkOpen => "link_open",
Action::ImageZoomIn => "image_zoom_in",
Action::ImageZoomOut => "image_zoom_out",
Action::ImageZoomReset => "image_zoom_reset",
Action::PdfNextPage => "pdf_next_page",
Action::PdfPrevPage => "pdf_prev_page",
Action::SortSet(SortKey::Name) => "sort_name",
Action::SortSet(SortKey::Size) => "sort_size",
Action::SortSet(SortKey::Modified) => "sort_modified",
Action::SortSet(SortKey::Ext) => "sort_ext",
Action::SortToggleReverse => "sort_toggle_reverse",
Action::SortToggleDirsFirst => "sort_toggle_dirs_first",
Action::BookmarkJump => "bookmark_jump",
Action::BookmarkEdit => "bookmark_edit",
Action::BookmarkDelete => "bookmark_delete",
Action::BookmarkClose => "bookmark_close",
Action::InfoClose => "info_close",
#[cfg(feature = "git")]
Action::GitDiffDiscard => "git_diff_discard",
#[cfg(feature = "git")]
Action::CycleDiffLayout => "cycle_diff_layout",
#[cfg(feature = "git")]
Action::GitStage => "git_stage",
#[cfg(feature = "git")]
Action::GitUnstage => "git_unstage",
#[cfg(feature = "git")]
Action::GitStageAll => "git_stage_all",
#[cfg(feature = "git")]
Action::GitUnstageAll => "git_unstage_all",
#[cfg(feature = "git")]
Action::GitDiscard => "git_discard",
#[cfg(feature = "git")]
Action::GitCommit => "git_commit",
#[cfg(feature = "git")]
Action::GitWorktreeDiff => "git_worktree_diff",
#[cfg(feature = "git")]
Action::GitOpenLog => "git_open_log",
#[cfg(feature = "git")]
Action::GitOpenGraph => "git_open_graph",
#[cfg(feature = "git")]
Action::GitOpenBranches => "git_open_branches",
#[cfg(feature = "git")]
Action::GitLaunchTool => "git_launch_tool",
#[cfg(feature = "git")]
Action::GitOpenSelectedDiff => "git_open_selected_diff",
#[cfg(feature = "git")]
Action::GitOpenDetail => "git_open_detail",
#[cfg(feature = "git")]
Action::GitGraphSetBase => "git_graph_set_base",
#[cfg(feature = "git")]
Action::GitGraphClearBase => "git_graph_clear_base",
#[cfg(feature = "git")]
Action::GitGraphOpenPicker => "git_graph_open_picker",
#[cfg(feature = "git")]
Action::GitGraphPickerToggle => "git_graph_picker_toggle",
#[cfg(feature = "git")]
Action::GitGraphPickerAll => "git_graph_picker_all",
#[cfg(feature = "git")]
Action::GitGraphPickerCurrentOnly => "git_graph_picker_current_only",
#[cfg(feature = "git")]
Action::GitGraphPickerMoveUp => "git_graph_picker_move_up",
#[cfg(feature = "git")]
Action::GitGraphPickerMoveDown => "git_graph_picker_move_down",
#[cfg(feature = "git")]
Action::BranchFilterStart => "branch_filter_start",
#[cfg(feature = "git")]
Action::BranchCheckout => "branch_checkout",
#[cfg(feature = "git")]
Action::BranchCreate => "branch_create",
#[cfg(feature = "git")]
Action::BranchDelete => "branch_delete",
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::ShortHash) => "git_copy_short_hash",
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::FullHash) => "git_copy_full_hash",
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::Subject) => "git_copy_subject",
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::Message) => "git_copy_message",
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::Author) => "git_copy_author",
#[cfg(feature = "git")]
Action::GitCopy(GitCopyKind::Date) => "git_copy_date",
#[cfg(feature = "git")]
Action::CopyBranchName => "copy_branch_name",
#[cfg(feature = "git")]
Action::GitClose => "git_close",
};
s.to_string()
}
fn binding_name(b: &Binding) -> String {
match b {
Binding::Run(a) => action_name(*a),
Binding::Leader(id) => format!("leader:{}", leader_name(*id)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn cfg_with(surface: &str, entries: &[(&str, &str)]) -> KeysFileConfig {
let mut table = HashMap::new();
for (k, v) in entries {
table.insert((*k).to_string(), (*v).to_string());
}
let mut cfg = KeysFileConfig::default();
cfg.surfaces.insert(surface.to_string(), table);
cfg
}
#[test]
fn defaults_tree_core_keys() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('d')),
Resolution::Action(Action::OpenGitDiffCursor)
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('a')),
Resolution::Action(Action::SetAnchor)
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('A')),
Resolution::Action(Action::ResetAnchor)
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('o')),
Resolution::Action(Action::OpenGitView)
);
}
#[test]
fn visual_q_quits() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::Visual, None, KeyPress::ch('q')),
Resolution::Action(Action::Quit)
);
}
#[test]
fn shift_q_quits_via_global_on_keymap_surfaces() {
let m = KeyMap::defaults(KeyScheme::Vim);
for sfc in [Surface::Tree, Surface::PreviewText, Surface::Visual] {
assert_eq!(
m.resolve(sfc, None, KeyPress::ch('Q')),
Resolution::Action(Action::Quit),
"Q が {sfc:?} で終了に解決する"
);
}
}
#[test]
fn leader_resolution() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch(' ')),
Resolution::EnterLeader(LeaderId::File)
);
assert_eq!(
m.resolve(Surface::Tree, Some(LeaderId::File), KeyPress::ch('d')),
Resolution::Action(Action::FileDelete)
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('y')),
Resolution::EnterLeader(LeaderId::Copy)
);
assert_eq!(
m.resolve(Surface::Tree, Some(LeaderId::Copy), KeyPress::ch('f')),
Resolution::Action(Action::CopyPath(CopyKind::Full))
);
assert_eq!(
m.resolve(Surface::Tree, Some(LeaderId::Copy), KeyPress::ch('z')),
Resolution::Unbound
);
}
#[test]
fn copy_leader_scope() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::PreviewText, None, KeyPress::ch('y')),
Resolution::EnterLeader(LeaderId::Copy)
);
assert_eq!(
m.resolve(Surface::PreviewImage, None, KeyPress::ch('y')),
Resolution::EnterLeader(LeaderId::Copy)
);
assert_eq!(
m.resolve(Surface::Visual, None, KeyPress::ch('y')),
Resolution::Unbound
);
}
#[test]
fn chord_parse() {
assert_eq!(
KeyChord::parse("space d").unwrap(),
KeyChord::Chord(KeyPress::ch(' '), KeyPress::ch('d'))
);
assert_eq!(
KeyChord::parse("ctrl-d").unwrap(),
KeyChord::Single(KeyPress::ctrl_ch('d'))
);
assert_eq!(
KeyChord::parse("c-d").unwrap(),
KeyChord::Single(KeyPress::ctrl_ch('d'))
);
assert_eq!(
KeyChord::parse("y f").unwrap(),
KeyChord::Chord(KeyPress::ch('y'), KeyPress::ch('f'))
);
assert_eq!(
KeyChord::parse("0").unwrap(),
KeyChord::Single(KeyPress::ch('0'))
);
assert_eq!(
KeyChord::parse("$").unwrap(),
KeyChord::Single(KeyPress::ch('$'))
);
assert_eq!(
KeyChord::parse("!").unwrap(),
KeyChord::Single(KeyPress::ch('!'))
);
assert_eq!(
KeyChord::parse("space").unwrap(),
KeyChord::Single(KeyPress::ch(' '))
);
assert_eq!(
KeyChord::parse("enter").unwrap(),
KeyChord::Single(KeyPress::key(KeyCode::Enter))
);
assert_eq!(
KeyChord::parse("G").unwrap(),
KeyChord::Single(KeyPress::ch('G'))
);
assert!(KeyChord::parse("").is_err());
assert!(KeyChord::parse("a b c").is_err());
assert!(KeyChord::parse("notakey").is_err());
}
#[test]
fn action_roundtrip() {
#[allow(unused_mut)]
let mut samples = vec![
Action::Noop,
Action::Navigate(Motion::PageDown),
Action::Navigate(Motion::LineHome),
Action::TabNew,
Action::ToggleHelp,
Action::CopyPath(CopyKind::Full),
Action::CopyPath(CopyKind::Parent),
Action::Quit,
Action::Refresh,
Action::SetAnchor,
Action::ResetAnchor,
Action::OpenGitDiffCursor,
Action::OpenGitView,
Action::FileDelete,
Action::SortSet(SortKey::Size),
Action::SortToggleReverse,
Action::InfoClose,
];
#[cfg(feature = "git")]
{
samples.push(Action::GitStage);
samples.push(Action::GitOpenGraph);
samples.push(Action::CycleDiffLayout);
samples.push(Action::BranchDelete);
samples.push(Action::GitClose);
}
for a in samples {
assert_eq!(action_from_str(&action_name(a)), Some(a), "roundtrip {a:?}");
}
assert_eq!(
action_from_str("navigate:page_down"),
Some(Action::Navigate(Motion::PageDown))
);
assert_eq!(action_from_str("totally_unknown"), None);
assert_eq!(action_from_str("tab_goto"), None);
}
#[test]
fn config_merge_override_add_noop() {
let cfg = cfg_with(
"tree",
&[
("d", "refresh"), ("X", "refresh"), ("i", "noop"), ("g s", "open_sort_menu"), ],
);
let m = KeyMap::from_config(KeyScheme::Vim, &cfg);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('d')),
Resolution::Action(Action::Refresh)
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('X')),
Resolution::Action(Action::Refresh)
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('i')),
Resolution::Unbound
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('g')),
Resolution::Action(Action::Navigate(Motion::Top))
);
assert!(
m.warnings.iter().any(|w| w.contains("chord prefix")),
"expected anonymous-prefix warning, got {:?}",
m.warnings
);
}
#[test]
fn config_leader_suffix_override() {
let cfg = cfg_with("tree", &[("y z", "copy_full")]);
let m = KeyMap::from_config(KeyScheme::Vim, &cfg);
assert_eq!(
m.resolve(Surface::Tree, Some(LeaderId::Copy), KeyPress::ch('z')),
Resolution::Action(Action::CopyPath(CopyKind::Full))
);
}
#[test]
fn validate_prefix_vs_single() {
let cfg = cfg_with("tree", &[("space", "quit")]);
let m = KeyMap::from_config(KeyScheme::Vim, &cfg);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch(' ')),
Resolution::EnterLeader(LeaderId::File)
);
assert_eq!(
m.conflicts
.iter()
.filter(|c| c.reason == ConflictKind::PrefixVsSingle)
.count(),
1
);
}
#[test]
fn validate_global_shadow() {
let cfg = cfg_with("tree", &[("t", "refresh")]);
let m = KeyMap::from_config(KeyScheme::Vim, &cfg);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('t')),
Resolution::Action(Action::TabNew)
);
assert_eq!(
m.conflicts
.iter()
.filter(|c| c.reason == ConflictKind::GlobalShadow)
.count(),
1
);
}
#[test]
fn fixed_key_rebind_rejected() {
let cfg = cfg_with("tree", &[("enter", "quit")]);
let m = KeyMap::from_config(KeyScheme::Vim, &cfg);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::key(KeyCode::Enter)),
Resolution::Unbound
);
assert!(m.warnings.iter().any(|w| w.contains("fixed key")));
}
#[test]
fn less_scheme_preview_paging() {
let m = KeyMap::defaults(KeyScheme::Less);
assert_eq!(
m.resolve(Surface::PreviewText, None, KeyPress::ch(' ')),
Resolution::Action(Action::Navigate(Motion::PageDown))
);
assert_eq!(
m.resolve(Surface::PreviewText, None, KeyPress::ch('d')),
Resolution::Action(Action::Navigate(Motion::HalfDown))
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch(' ')),
Resolution::EnterLeader(LeaderId::File)
);
}
#[test]
fn vim_scheme_preview_paging() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::PreviewText, None, KeyPress::ctrl_ch('f')),
Resolution::Action(Action::Navigate(Motion::PageDown))
);
assert_eq!(
m.resolve(Surface::PreviewText, None, KeyPress::ctrl_ch('d')),
Resolution::Action(Action::Navigate(Motion::HalfDown))
);
assert_eq!(
m.resolve(Surface::PreviewText, None, KeyPress::ch('f')),
Resolution::Unbound
);
}
#[test]
fn scheme_does_not_affect_sort() {
let vim = KeyMap::defaults(KeyScheme::Vim);
let less = KeyMap::defaults(KeyScheme::Less);
for kp in [KeyPress::ch('n'), KeyPress::ch('s'), KeyPress::ch('r')] {
assert_eq!(
vim.resolve(Surface::Sort, None, kp),
less.resolve(Surface::Sort, None, kp)
);
}
}
#[test]
fn tree_has_no_horizontal_motion() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('h')),
Resolution::Action(Action::TreeLeave)
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('0')),
Resolution::Unbound
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('$')),
Resolution::Unbound
);
}
#[test]
fn global_tab_inheritance() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('t')),
Resolution::Action(Action::TabNew)
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('1')),
Resolution::Action(Action::TabGoto(0))
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('9')),
Resolution::Action(Action::TabGoto(8))
);
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('?')),
Resolution::Action(Action::ToggleHelp)
);
assert_eq!(
m.resolve(Surface::Filter, None, KeyPress::ch('t')),
Resolution::Unbound
);
}
#[test]
fn surface_predicates() {
assert!(Surface::Filter.is_text_input());
assert!(!Surface::Tree.is_text_input());
assert!(Surface::DialogConfirmDelete.is_modal_confirm());
assert!(!Surface::Sort.is_modal_confirm());
assert!(Surface::Tree.allows_tabs());
assert!(!Surface::Filter.allows_tabs());
assert!(!Surface::DialogConfirmDelete.allows_tabs());
assert!(Surface::Tree.inherits_copy_leader());
assert!(!Surface::Visual.inherits_copy_leader());
}
#[test]
fn unknown_surface_warns() {
let cfg = cfg_with("nonsense", &[("d", "refresh")]);
let m = KeyMap::from_config(KeyScheme::Vim, &cfg);
assert!(m.warnings.iter().any(|w| w.contains("unknown key surface")));
assert_eq!(
m.resolve(Surface::Tree, None, KeyPress::ch('d')),
Resolution::Action(Action::OpenGitDiffCursor)
);
}
#[cfg(feature = "git")]
#[test]
fn git_changes_scheme_invariant() {
let vim = KeyMap::defaults(KeyScheme::Vim);
let less = KeyMap::defaults(KeyScheme::Less);
for kp in [KeyPress::ch('s'), KeyPress::ch('j'), KeyPress::ch('d')] {
assert_eq!(
vim.resolve(Surface::GitChanges, None, kp),
less.resolve(Surface::GitChanges, None, kp)
);
}
}
#[cfg(feature = "git")]
#[test]
fn git_detail_navigate_coverage() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::GitDetail, None, KeyPress::ch('h')),
Resolution::Action(Action::Navigate(Motion::Left))
);
assert_eq!(
m.resolve(Surface::GitDetail, None, KeyPress::ch('l')),
Resolution::Action(Action::Navigate(Motion::Right))
);
assert_eq!(
m.resolve(Surface::GitDetail, None, KeyPress::ch('0')),
Resolution::Action(Action::Navigate(Motion::LineHome))
);
assert_eq!(
m.resolve(Surface::GitDetail, None, KeyPress::ctrl_ch('f')),
Resolution::Action(Action::Navigate(Motion::PageDown))
);
}
#[cfg(feature = "git")]
#[test]
fn git_surfaces_copy_bindings() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::GitChanges, None, KeyPress::ch('y')),
Resolution::EnterLeader(LeaderId::Copy)
);
for sfc in [Surface::GitLog, Surface::GitGraph, Surface::GitDetail] {
assert_eq!(
m.resolve(sfc, None, KeyPress::ch('y')),
Resolution::EnterLeader(LeaderId::GitCopy),
"{sfc:?} の y は GitCopy リーダー"
);
}
assert_eq!(
m.resolve(Surface::GitLog, Some(LeaderId::GitCopy), KeyPress::ch('m')),
Resolution::Action(Action::GitCopy(GitCopyKind::Message))
);
assert_eq!(
m.resolve(
Surface::GitGraph,
Some(LeaderId::GitCopy),
KeyPress::ch('h')
),
Resolution::Action(Action::GitCopy(GitCopyKind::FullHash))
);
assert_eq!(
m.resolve(Surface::GitBranches, None, KeyPress::ch('y')),
Resolution::Action(Action::CopyBranchName)
);
}
#[cfg(feature = "git")]
#[test]
fn git_changes_mnemonics() {
let m = KeyMap::defaults(KeyScheme::Vim);
assert_eq!(
m.resolve(Surface::GitChanges, None, KeyPress::ch('d')),
Resolution::Action(Action::GitWorktreeDiff)
);
assert_eq!(
m.resolve(Surface::GitChanges, None, KeyPress::ch('l')),
Resolution::Action(Action::GitOpenLog)
);
assert_eq!(
m.resolve(Surface::GitChanges, None, KeyPress::ch('g')),
Resolution::Action(Action::GitOpenGraph)
);
assert_eq!(
m.resolve(Surface::GitChanges, None, KeyPress::ch('!')),
Resolution::Action(Action::GitLaunchTool)
);
}
#[test]
fn parse_code_named_keys_and_invalid() {
assert_eq!(KeyPress::parse_code("space").unwrap(), KeyCode::Char(' '));
assert_eq!(KeyPress::parse_code("tab").unwrap(), KeyCode::Tab);
assert_eq!(KeyPress::parse_code("backtab").unwrap(), KeyCode::BackTab);
assert_eq!(KeyPress::parse_code("Enter").unwrap(), KeyCode::Enter);
assert_eq!(KeyPress::parse_code("return").unwrap(), KeyCode::Enter);
assert_eq!(KeyPress::parse_code("ESC").unwrap(), KeyCode::Esc);
assert_eq!(KeyPress::parse_code("escape").unwrap(), KeyCode::Esc);
assert_eq!(
KeyPress::parse_code("backspace").unwrap(),
KeyCode::Backspace
);
assert_eq!(KeyPress::parse_code("del").unwrap(), KeyCode::Delete);
assert_eq!(KeyPress::parse_code("delete").unwrap(), KeyCode::Delete);
assert_eq!(KeyPress::parse_code("up").unwrap(), KeyCode::Up);
assert_eq!(KeyPress::parse_code("down").unwrap(), KeyCode::Down);
assert_eq!(KeyPress::parse_code("left").unwrap(), KeyCode::Left);
assert_eq!(KeyPress::parse_code("right").unwrap(), KeyCode::Right);
assert_eq!(KeyPress::parse_code("home").unwrap(), KeyCode::Home);
assert_eq!(KeyPress::parse_code("end").unwrap(), KeyCode::End);
assert_eq!(KeyPress::parse_code("pgup").unwrap(), KeyCode::PageUp);
assert_eq!(KeyPress::parse_code("pageup").unwrap(), KeyCode::PageUp);
assert_eq!(KeyPress::parse_code("pgdn").unwrap(), KeyCode::PageDown);
assert_eq!(KeyPress::parse_code("pagedown").unwrap(), KeyCode::PageDown);
assert_eq!(KeyPress::parse_code("a").unwrap(), KeyCode::Char('a'));
assert_eq!(KeyPress::parse_code("Z").unwrap(), KeyCode::Char('Z'));
assert_eq!(KeyPress::parse_code("$").unwrap(), KeyCode::Char('$'));
assert!(KeyPress::parse_code("abc").is_err(), "未知の複数文字は Err");
assert!(KeyPress::parse_code("nope").is_err());
}
#[test]
fn leader_menu_set_find_and_remove() {
let mut menu = LeaderMenu {
id: LeaderId::File,
title: Msg::StFilter,
items: Vec::new(),
};
menu.set(KeyPress::ch('a'), Action::ToggleHelp);
menu.set(KeyPress::ch('b'), Action::ToggleInfo);
assert_eq!(menu.items.len(), 2);
assert_eq!(menu.find(KeyPress::ch('a')), Some(Action::ToggleHelp));
menu.set(KeyPress::ch('a'), Action::Refresh);
assert_eq!(menu.items.len(), 2, "同一キーは置換");
assert_eq!(menu.find(KeyPress::ch('a')), Some(Action::Refresh));
menu.remove(KeyPress::ch('a'));
assert_eq!(menu.items.len(), 1);
assert_eq!(
menu.find(KeyPress::ch('a')),
None,
"remove 後は見つからない"
);
assert_eq!(
menu.find(KeyPress::ch('b')),
Some(Action::ToggleInfo),
"他は残る"
);
menu.remove(KeyPress::ch('z'));
assert_eq!(menu.items.len(), 1);
}
}