use crate::error::{GwmError, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::fmt;
macro_rules! define_actions {
($( $variant:ident => $slug:literal ),* $(,)?) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
$( $variant, )*
}
impl Action {
pub fn slug(self) -> &'static str {
match self {
$( Action::$variant => $slug, )*
}
}
pub fn from_slug(s: &str) -> Option<Self> {
match s {
$( $slug => Some(Action::$variant), )*
_ => None,
}
}
pub fn all() -> impl Iterator<Item = Self> {
[ $( Action::$variant, )* ].into_iter()
}
}
pub const ACTIONS: &[(Action, &str)] = &[
$( (Action::$variant, $slug), )*
];
};
}
define_actions! {
Down => "down",
Up => "up",
Top => "top",
Bottom => "bottom",
WtScrollDown => "wt_scroll_down",
WtScrollUp => "wt_scroll_up",
ToggleSidebar => "toggle_sidebar",
ToggleSidebarMode => "toggle_sidebar_mode",
CycleSidebarLayout => "cycle_sidebar_layout",
ToggleSidebarPosition => "toggle_sidebar_position",
FocusSwap => "focus_swap",
FocusWorktrees => "focus_worktrees",
FocusStatus => "focus_status",
Filter => "filter",
Refresh => "refresh",
Sync => "sync",
Create => "create",
DeleteConfirm => "delete",
Bootstrap => "bootstrap",
ToggleDeleteBranch => "delete_branch",
Pull => "pull",
Push => "push",
EditWorktree => "edit_worktree",
ExitToWorktree => "exit_to_worktree",
LazyGitPty => "lazygit_pty",
LazyGitFullscreen => "lazygit_fullscreen",
ReviewFullscreen => "review_fullscreen",
ReviewPty => "review_pty",
YankPath => "yank_path",
YankBranchName => "yank_branch_name",
YankWorktreeName => "yank_worktree_name",
TerminalPty => "terminal_pty",
TerminalFullscreen => "terminal_fullscreen",
BrowseLinks => "browse_links",
OpenDocs => "open_docs",
LinkPrompt => "link",
FetchGithub => "fetch_github",
MuxPane => "mux_pane",
Macro1 => "macro_one",
Macro2 => "macro_two",
CommandLogs => "command_logs",
ConfigPanel => "config_panel",
CiChecks => "ci_checks",
ExecOverlay => "exec_overlay",
CleanOverlay => "clean_overlay",
AgentSessions => "agent_sessions",
Help => "help",
Quit => "quit",
CommandPalette => "command_palette",
}
impl Action {
pub fn from_slug_compat(s: &str) -> Option<Self> {
if let Some(a) = Self::from_slug(s) {
return Some(a);
}
COMPAT_ALIASES.iter().find(|(slug, _)| *slug == s).map(|(_, a)| *a)
}
pub fn is_repo_mutating(self) -> bool {
matches!(
self,
Action::Create
| Action::DeleteConfirm
| Action::Bootstrap
| Action::Sync
| Action::Pull
| Action::Push
| Action::EditWorktree
| Action::LinkPrompt
| Action::FetchGithub
| Action::ExecOverlay
| Action::CleanOverlay
)
}
pub fn compat_alias_slugs(self) -> impl Iterator<Item = &'static str> {
COMPAT_ALIASES
.iter()
.filter(move |(_, a)| *a == self)
.map(|(slug, _)| *slug)
}
}
const COMPAT_ALIASES: &[(&str, Action)] = &[
("git_tui", Action::LazyGitFullscreen),
("git_tui_overlay", Action::LazyGitPty),
("review", Action::ReviewFullscreen),
("review_overlay", Action::ReviewPty),
("yank", Action::YankPath),
("open", Action::TerminalFullscreen),
("open_terminal_overlay", Action::TerminalPty),
("open_menu", Action::BrowseLinks),
];
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct KeyStroke {
pub code: KeyCode,
pub modifiers: KeyModifiers,
}
impl KeyStroke {
pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
let (code, modifiers) = Self::normalize(code, Self::sanitize(modifiers));
Self { code, modifiers }
}
pub fn from_event(ev: &KeyEvent) -> Self {
Self::new(ev.code, ev.modifiers)
}
fn sanitize(m: KeyModifiers) -> KeyModifiers {
m & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT)
}
fn normalize(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
match code {
KeyCode::Char(c) if modifiers.contains(KeyModifiers::SHIFT) => {
(KeyCode::Char(c.to_ascii_uppercase()), modifiers - KeyModifiers::SHIFT)
}
KeyCode::BackTab if modifiers.contains(KeyModifiers::SHIFT) => {
(KeyCode::BackTab, modifiers - KeyModifiers::SHIFT)
}
_ => (code, modifiers),
}
}
pub fn parse_chord(s: &str) -> Result<Vec<KeyStroke>> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(GwmError::Config(format!("keymap: empty key string {:?}", s)));
}
trimmed.split_whitespace().map(Self::parse_single).collect()
}
fn parse_single(token: &str) -> Result<KeyStroke> {
if token.is_empty() {
return Err(GwmError::Config("keymap: empty keystroke token".into()));
}
let parts: Vec<&str> = token.split('+').collect();
if parts.iter().any(|p| p.is_empty()) {
return Err(GwmError::Config(format!("keymap: dangling '+' in {:?}", token)));
}
let (key_str, mod_strs) = parts.split_last().expect("token is non-empty, split_last cannot fail");
let mut modifiers = KeyModifiers::empty();
for m in mod_strs {
let bit = match *m {
"Ctrl" => KeyModifiers::CONTROL,
"Alt" => KeyModifiers::ALT,
"Shift" => KeyModifiers::SHIFT,
other => {
return Err(GwmError::Config(format!(
"keymap: unknown modifier {:?} in {:?}",
other, token
)))
}
};
if modifiers.contains(bit) {
return Err(GwmError::Config(format!(
"keymap: duplicate modifier {:?} in {:?}",
m, token
)));
}
modifiers |= bit;
}
let code = parse_keycode(key_str, token)?;
Ok(KeyStroke::new(code, modifiers))
}
}
fn parse_keycode(s: &str, full_token: &str) -> Result<KeyCode> {
let code = match s {
"Tab" => KeyCode::Tab,
"Enter" => KeyCode::Enter,
"Esc" => KeyCode::Esc,
"Up" => KeyCode::Up,
"Down" => KeyCode::Down,
"Left" => KeyCode::Left,
"Right" => KeyCode::Right,
"Backspace" => KeyCode::Backspace,
"BackTab" => KeyCode::BackTab,
"Home" => KeyCode::Home,
"End" => KeyCode::End,
"PageUp" => KeyCode::PageUp,
"PageDown" => KeyCode::PageDown,
"Insert" => KeyCode::Insert,
"Delete" => KeyCode::Delete,
"Space" => KeyCode::Char(' '),
other if other.starts_with('F') && other.len() > 1 => {
let n: u8 = other[1..]
.parse()
.map_err(|_| GwmError::Config(format!("keymap: invalid function key {:?}", other)))?;
if !(1..=12).contains(&n) {
return Err(GwmError::Config(format!(
"keymap: function key out of range {:?} (expected F1..=F12)",
other
)));
}
KeyCode::F(n)
}
other => {
let mut chars = other.chars();
let (first, second) = (chars.next(), chars.next());
match (first, second) {
(Some(c), None) => KeyCode::Char(c),
_ => {
return Err(GwmError::Config(format!(
"keymap: unknown key {:?} in {:?}",
other, full_token
)))
}
}
}
};
Ok(code)
}
impl fmt::Display for KeyStroke {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.modifiers.contains(KeyModifiers::CONTROL) {
write!(f, "Ctrl+")?;
}
if self.modifiers.contains(KeyModifiers::ALT) {
write!(f, "Alt+")?;
}
if self.modifiers.contains(KeyModifiers::SHIFT) {
write!(f, "Shift+")?;
}
match self.code {
KeyCode::Char(' ') => write!(f, "Space"),
KeyCode::Char(c) => write!(f, "{c}"),
KeyCode::Tab => write!(f, "Tab"),
KeyCode::Enter => write!(f, "Enter"),
KeyCode::Esc => write!(f, "Esc"),
KeyCode::Up => write!(f, "Up"),
KeyCode::Down => write!(f, "Down"),
KeyCode::Left => write!(f, "Left"),
KeyCode::Right => write!(f, "Right"),
KeyCode::Backspace => write!(f, "Backspace"),
KeyCode::BackTab => write!(f, "BackTab"),
KeyCode::Home => write!(f, "Home"),
KeyCode::End => write!(f, "End"),
KeyCode::PageUp => write!(f, "PageUp"),
KeyCode::PageDown => write!(f, "PageDown"),
KeyCode::Insert => write!(f, "Insert"),
KeyCode::Delete => write!(f, "Delete"),
KeyCode::F(n) => write!(f, "F{n}"),
other => write!(f, "{other:?}"),
}
}
}
fn format_chord(strokes: &[KeyStroke]) -> String {
strokes.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" ")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Default,
UserConfig,
}
#[derive(Debug, Clone)]
pub struct Binding {
pub action: Action,
pub chords: Vec<Vec<KeyStroke>>,
pub source: Source,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ChordResolution {
Matched(Action),
PendingPrefix,
NoMatch,
}
#[derive(Debug, Clone)]
pub struct Keymap {
entries: Vec<Binding>,
}
impl Keymap {
pub fn defaults() -> Self {
let entries = vec![
def(Action::Down, &["j", "Down"]),
def(Action::Up, &["k", "Up"]),
def(Action::Top, &["g g"]),
def(Action::Bottom, &["G", "End"]),
def(Action::WtScrollDown, &["J"]),
def(Action::WtScrollUp, &["K"]),
def(Action::ToggleSidebar, &["V"]),
def(Action::ToggleSidebarMode, &["S"]),
def(Action::CycleSidebarLayout, &["Space"]),
def(Action::ToggleSidebarPosition, &["v"]),
def(Action::FocusSwap, &["Tab"]),
def(Action::FocusWorktrees, &["1"]),
def(Action::FocusStatus, &["2"]),
def(Action::CommandLogs, &["3"]),
def(Action::ConfigPanel, &["4"]),
def(Action::CiChecks, &["C"]),
def(Action::ExecOverlay, &["x"]),
def(Action::AgentSessions, &["a"]),
def(Action::CleanOverlay, &["X"]),
def(Action::Filter, &["/"]),
def(Action::Refresh, &["f"]),
def(Action::Sync, &["s"]),
def(Action::Create, &["n"]),
def(Action::DeleteConfirm, &["d"]),
def(Action::Bootstrap, &["b"]),
def(Action::ToggleDeleteBranch, &["D"]),
def(Action::Pull, &["p"]),
def(Action::Push, &["P"]),
def(Action::EditWorktree, &["c"]),
def(Action::ExitToWorktree, &["e"]),
def(Action::LazyGitPty, &["l"]),
def(Action::LazyGitFullscreen, &["L"]),
def(Action::ReviewFullscreen, &["R"]),
def(Action::ReviewPty, &["r"]),
def(Action::YankPath, &["Y"]),
def(Action::YankBranchName, &["y"]),
def(Action::YankWorktreeName, &["w"]),
def(Action::TerminalPty, &["o"]),
def(Action::TerminalFullscreen, &["O"]),
def(Action::BrowseLinks, &["B"]),
def(Action::OpenDocs, &["."]),
def(Action::LinkPrompt, &["i"]),
def(Action::FetchGithub, &["F"]),
def(Action::MuxPane, &["t"]),
def(Action::Macro1, &["h"]),
def(Action::Macro2, &["H"]),
def(Action::Help, &["?"]),
def(Action::Quit, &["q"]),
def(Action::CommandPalette, &[":"]),
];
Self { entries }
}
pub fn apply_override(&mut self, action: Action, chords: Vec<Vec<KeyStroke>>) -> Result<()> {
let new_chord_set: std::collections::HashSet<&[KeyStroke]> = chords.iter().map(|c| c.as_slice()).collect();
let mut candidate: Vec<(Action, Vec<Vec<KeyStroke>>)> = self
.entries
.iter()
.map(|b| {
if b.action == action {
(b.action, chords.clone())
} else if b.source == Source::Default {
let pruned: Vec<Vec<KeyStroke>> = b
.chords
.iter()
.filter(|c| !new_chord_set.contains(c.as_slice()))
.cloned()
.collect();
(b.action, pruned)
} else {
(b.action, b.chords.clone())
}
})
.collect();
if !candidate.iter().any(|(a, _)| *a == action) {
candidate.push((action, chords.clone()));
}
Self::validate(&candidate)?;
for entry in self.entries.iter_mut() {
if entry.action != action && entry.source == Source::Default {
entry.chords.retain(|c| !new_chord_set.contains(c.as_slice()));
}
}
let mut replaced = false;
for entry in self.entries.iter_mut() {
if entry.action == action {
entry.chords = chords.clone();
entry.source = Source::UserConfig;
replaced = true;
break;
}
}
if !replaced {
self.entries.push(Binding {
action,
chords,
source: Source::UserConfig,
});
}
Ok(())
}
fn validate(entries: &[(Action, Vec<Vec<KeyStroke>>)]) -> Result<()> {
let mut all: Vec<(&[KeyStroke], Action)> = Vec::new();
for (action, chords) in entries {
for chord in chords {
if chord.is_empty() {
return Err(GwmError::Config(format!(
"keymap: empty chord bound to {:?}",
action.slug()
)));
}
all.push((chord.as_slice(), *action));
}
}
for i in 0..all.len() {
for j in (i + 1)..all.len() {
if all[i].0 == all[j].0 {
if all[i].1 != all[j].1 {
return Err(GwmError::Config(format!(
"keymap: chord {:?} bound to both {:?} and {:?} — conflict",
format_chord(all[i].0),
all[i].1.slug(),
all[j].1.slug()
)));
}
continue;
}
let (short, long) = if all[i].0.len() < all[j].0.len() {
(i, j)
} else {
(j, i)
};
if all[short].0.len() < all[long].0.len() && all[long].0.starts_with(all[short].0) {
return Err(GwmError::Config(format!(
"keymap: chord {:?} (action {:?}) is a prefix of {:?} (action {:?}) — refused at load time so the event loop never has to time out",
format_chord(all[short].0),
all[short].1.slug(),
format_chord(all[long].0),
all[long].1.slug()
)));
}
}
}
Ok(())
}
pub fn lookup(&self, keys: &[KeyStroke]) -> ChordResolution {
let mut pending = false;
for entry in &self.entries {
for chord in &entry.chords {
if chord.as_slice() == keys {
return ChordResolution::Matched(entry.action);
}
if chord.len() > keys.len() && chord.starts_with(keys) {
pending = true;
}
}
}
if pending {
ChordResolution::PendingPrefix
} else {
ChordResolution::NoMatch
}
}
pub fn list(&self) -> Vec<Binding> {
self.entries.clone()
}
pub fn primary_chord(&self, action: Action) -> Option<String> {
self
.entries
.iter()
.find(|b| b.action == action)
.and_then(|b| b.chords.first())
.map(|chord| format_chord(chord))
}
pub fn keys_display(&self, action: Action) -> String {
self
.entries
.iter()
.find(|b| b.action == action)
.map(|b| b.chords.iter().map(|c| format_chord(c)).collect::<Vec<_>>().join(", "))
.unwrap_or_default()
}
}
fn def(action: Action, chord_literals: &[&str]) -> Binding {
let chords = chord_literals
.iter()
.map(|s| {
KeyStroke::parse_chord(s).unwrap_or_else(|e| panic!("default keymap chord {:?} failed to parse: {}", s, e))
})
.collect();
Binding {
action,
chords,
source: Source::Default,
}
}