use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use anyhow::{Result, bail};
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use crate::i18n::tr_f;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum Action {
TakeLocal,
TakeRemote,
IgnoreChunk,
UndoChunk,
UndoFile,
EditChunk,
ApplyNonConflict,
NextChange,
PrevChange,
NextConflict,
PrevConflict,
ScrollDown,
ScrollUp,
CopyChunk,
CopyFile,
CopyLocal,
CopyRemote,
WriteFile,
NextFile,
ToggleFold,
Quit,
Help,
}
static ACTION_NAMES: &[(&str, Action)] = &[
("take-local", Action::TakeLocal),
("take-remote", Action::TakeRemote),
("ignore", Action::IgnoreChunk),
("undo", Action::UndoChunk),
("undo-file", Action::UndoFile),
("edit", Action::EditChunk),
("apply-all", Action::ApplyNonConflict),
("next-change", Action::NextChange),
("prev-change", Action::PrevChange),
("next-conflict", Action::NextConflict),
("prev-conflict", Action::PrevConflict),
("scroll-down", Action::ScrollDown),
("scroll-up", Action::ScrollUp),
("copy-chunk", Action::CopyChunk),
("copy-file", Action::CopyFile),
("copy-local", Action::CopyLocal),
("copy-remote", Action::CopyRemote),
("write", Action::WriteFile),
("next-file", Action::NextFile),
("fold", Action::ToggleFold),
("quit", Action::Quit),
("help", Action::Help),
];
struct Binding {
id: Action,
keys: &'static [(KeyCode, KeyModifiers, Action)],
hint: Option<&'static str>,
help: &'static str,
}
static BINDINGS: &[Binding] = &[
Binding {
id: Action::TakeLocal,
keys: &[
(KeyCode::Char('h'), KeyModifiers::NONE, Action::TakeLocal),
(KeyCode::Left, KeyModifiers::NONE, Action::TakeLocal),
],
hint: Some("ui.hint_left"),
help: "ui.help_left",
},
Binding {
id: Action::TakeRemote,
keys: &[
(KeyCode::Char('l'), KeyModifiers::NONE, Action::TakeRemote),
(KeyCode::Right, KeyModifiers::NONE, Action::TakeRemote),
],
hint: Some("ui.hint_right"),
help: "ui.help_right",
},
Binding {
id: Action::IgnoreChunk,
keys: &[(KeyCode::Char('x'), KeyModifiers::NONE, Action::IgnoreChunk)],
hint: Some("ui.hint_ignore"),
help: "ui.help_ignore",
},
Binding {
id: Action::UndoChunk,
keys: &[
(KeyCode::Char('u'), KeyModifiers::NONE, Action::UndoChunk),
(KeyCode::Char('U'), KeyModifiers::NONE, Action::UndoFile),
],
hint: Some("ui.hint_undo"),
help: "ui.help_undo",
},
Binding {
id: Action::EditChunk,
keys: &[(KeyCode::Char('e'), KeyModifiers::NONE, Action::EditChunk)],
hint: Some("ui.hint_edit"),
help: "ui.help_edit",
},
Binding {
id: Action::ApplyNonConflict,
keys: &[(
KeyCode::Char('a'),
KeyModifiers::NONE,
Action::ApplyNonConflict,
)],
hint: None,
help: "ui.help_apply",
},
Binding {
id: Action::WriteFile,
keys: &[(KeyCode::Char('w'), KeyModifiers::NONE, Action::WriteFile)],
hint: Some("ui.hint_write"),
help: "ui.help_write",
},
Binding {
id: Action::Quit,
keys: &[(KeyCode::Char('q'), KeyModifiers::NONE, Action::Quit)],
hint: Some("ui.hint_quit"),
help: "ui.help_quit",
},
Binding {
id: Action::NextChange,
keys: &[
(KeyCode::Char('j'), KeyModifiers::NONE, Action::NextChange),
(KeyCode::Down, KeyModifiers::NONE, Action::NextChange),
(KeyCode::Char('k'), KeyModifiers::NONE, Action::PrevChange),
(KeyCode::Up, KeyModifiers::NONE, Action::PrevChange),
],
hint: None,
help: "ui.help_move",
},
Binding {
id: Action::NextConflict,
keys: &[
(KeyCode::Char('n'), KeyModifiers::NONE, Action::NextConflict),
(KeyCode::Char('p'), KeyModifiers::NONE, Action::PrevConflict),
],
hint: Some("ui.hint_conflict"),
help: "ui.help_jump",
},
Binding {
id: Action::ScrollDown,
keys: &[
(
KeyCode::Char('d'),
KeyModifiers::CONTROL,
Action::ScrollDown,
),
(KeyCode::Char('u'), KeyModifiers::CONTROL, Action::ScrollUp),
],
hint: None,
help: "ui.help_scroll",
},
Binding {
id: Action::NextFile,
keys: &[(KeyCode::Tab, KeyModifiers::NONE, Action::NextFile)],
hint: Some("ui.hint_file"),
help: "ui.help_tab",
},
Binding {
id: Action::ToggleFold,
keys: &[(KeyCode::Char('z'), KeyModifiers::NONE, Action::ToggleFold)],
hint: Some("ui.hint_fold"),
help: "ui.help_fold",
},
Binding {
id: Action::CopyChunk,
keys: &[(KeyCode::Char('y'), KeyModifiers::NONE, Action::CopyChunk)],
hint: None,
help: "ui.help_copy_chunk",
},
Binding {
id: Action::CopyFile,
keys: &[(KeyCode::Char('Y'), KeyModifiers::NONE, Action::CopyFile)],
hint: None,
help: "ui.help_copy_file",
},
Binding {
id: Action::CopyLocal,
keys: &[
(KeyCode::Char('H'), KeyModifiers::NONE, Action::CopyLocal),
(KeyCode::Char('L'), KeyModifiers::NONE, Action::CopyRemote),
],
hint: None,
help: "ui.help_copy_sides",
},
Binding {
id: Action::Help,
keys: &[(KeyCode::Char('?'), KeyModifiers::NONE, Action::Help)],
hint: Some("ui.hint_help"),
help: "ui.help_help",
},
];
static HINT_LAYOUT: &[Action] = &[
Action::TakeLocal,
Action::TakeRemote,
Action::IgnoreChunk,
Action::UndoChunk,
Action::EditChunk,
Action::NextConflict,
Action::WriteFile,
Action::NextFile,
Action::ToggleFold,
Action::Quit,
Action::Help,
];
static HELP_LEFT: &[Action] = &[
Action::TakeLocal,
Action::TakeRemote,
Action::IgnoreChunk,
Action::UndoChunk,
Action::EditChunk,
Action::ApplyNonConflict,
Action::WriteFile,
Action::Quit,
];
static HELP_RIGHT: &[Action] = &[
Action::NextChange,
Action::NextConflict,
Action::ScrollDown,
Action::NextFile,
Action::ToggleFold,
Action::CopyChunk,
Action::CopyFile,
Action::CopyLocal,
Action::Help,
];
#[derive(Debug)]
struct EffectiveBinding {
id: Action,
keys: Vec<(KeyCode, KeyModifiers, Action)>,
label: String,
hint_label: String,
hint: Option<&'static str>,
help: &'static str,
}
static EFFECTIVE: OnceLock<Vec<EffectiveBinding>> = OnceLock::new();
pub(crate) fn init(overrides: &HashMap<String, String>) -> Result<()> {
let table = build(overrides)?;
let _ = EFFECTIVE.set(table);
Ok(())
}
fn effective() -> &'static [EffectiveBinding] {
EFFECTIVE.get_or_init(|| build(&HashMap::new()).unwrap_or_default())
}
fn build(overrides: &HashMap<String, String>) -> Result<Vec<EffectiveBinding>> {
let mut parsed: HashMap<Action, (KeyCode, KeyModifiers)> = HashMap::new();
for (name, desc) in overrides {
let Some(&(_, action)) = ACTION_NAMES.iter().find(|(n, _)| n == name) else {
let list: Vec<&str> = ACTION_NAMES.iter().map(|(n, _)| *n).collect();
bail!(
"{}",
tr_f(
"config.bad_action",
&[("name", name), ("list", &list.join(", "))],
)
);
};
let Some(key) = parse_key(desc) else {
bail!(
"{}",
tr_f("config.bad_key", &[("name", name), ("key", desc)])
);
};
parsed.insert(action, key);
}
let mut table = Vec::with_capacity(BINDINGS.len());
for binding in BINDINGS {
let mut keys: Vec<(KeyCode, KeyModifiers, Action)> = Vec::new();
let mut replaced: Vec<Action> = Vec::new();
for (code, mods, action) in binding.keys {
match parsed.get(action) {
Some(&(new_code, new_mods)) => {
if !replaced.contains(action) {
keys.push((new_code, new_mods, *action));
replaced.push(*action);
}
}
None => keys.push((*code, *mods, *action)),
}
}
let (label, hint_label) = derive_labels(&keys);
table.push(EffectiveBinding {
id: binding.id,
keys,
label,
hint_label,
hint: binding.hint,
help: binding.help,
});
}
let mut seen: HashSet<(KeyCode, KeyModifiers)> = HashSet::new();
for (code, mods, _) in table.iter().flat_map(|b| &b.keys) {
if !seen.insert((*code, *mods)) {
bail!(
"{}",
tr_f(
"config.key_conflict",
&[("key", &key_display(*code, *mods, false))],
)
);
}
}
Ok(table)
}
fn parse_key(desc: &str) -> Option<(KeyCode, KeyModifiers)> {
let parts: Vec<&str> = desc.split('+').map(str::trim).collect();
let (mod_parts, key_part) = parts.split_at(parts.len() - 1);
let name = *key_part.first()?;
if name.is_empty() {
return None;
}
let mut mods = KeyModifiers::NONE;
for part in mod_parts {
match part.to_ascii_lowercase().as_str() {
"ctrl" | "control" => mods |= KeyModifiers::CONTROL,
"alt" => mods |= KeyModifiers::ALT,
"shift" => mods |= KeyModifiers::SHIFT,
_ => return None,
}
}
let lower = name.to_ascii_lowercase();
let mut code = match lower.as_str() {
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"tab" => KeyCode::Tab,
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"space" => KeyCode::Char(' '),
"backspace" => KeyCode::Backspace,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" => KeyCode::PageUp,
"pagedown" => KeyCode::PageDown,
"delete" | "del" => KeyCode::Delete,
"insert" => KeyCode::Insert,
f if f.len() >= 2 && f.starts_with('f') && f[1..].chars().all(|c| c.is_ascii_digit()) => {
let n: u8 = f[1..].parse().ok()?;
if !(1..=12).contains(&n) {
return None;
}
KeyCode::F(n)
}
_ => {
let mut chars = name.chars();
let (Some(c), None) = (chars.next(), chars.next()) else {
return None;
};
KeyCode::Char(c)
}
};
if mods.contains(KeyModifiers::SHIFT)
&& let KeyCode::Char(c) = code
{
code = KeyCode::Char(c.to_ascii_uppercase());
mods -= KeyModifiers::SHIFT;
}
Some((code, mods))
}
fn key_display(code: KeyCode, mods: KeyModifiers, compact: bool) -> String {
let base = match code {
KeyCode::Char(' ') => "Space".to_owned(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Left => "←".to_owned(),
KeyCode::Right => "→".to_owned(),
KeyCode::Up => "↑".to_owned(),
KeyCode::Down => "↓".to_owned(),
KeyCode::Tab => if compact { "⇥" } else { "Tab" }.to_owned(),
KeyCode::F(n) => format!("F{n}"),
other => format!("{other:?}"),
};
let mut out = String::new();
if mods.contains(KeyModifiers::CONTROL) {
out.push_str("ctrl+");
}
if mods.contains(KeyModifiers::ALT) {
out.push_str("alt+");
}
out.push_str(&base);
out
}
fn derive_labels(keys: &[(KeyCode, KeyModifiers, Action)]) -> (String, String) {
let mut actions: Vec<Action> = Vec::new();
for (_, _, action) in keys {
if !actions.contains(action) {
actions.push(*action);
}
}
let first_key = |action: Action, compact: bool| -> Option<String> {
keys.iter()
.find(|(_, _, a)| *a == action)
.map(|(c, m, _)| key_display(*c, *m, compact))
};
let label = if actions.len() == 1 {
keys.iter()
.map(|(c, m, _)| key_display(*c, *m, false))
.collect::<Vec<_>>()
.join(" / ")
} else {
actions
.iter()
.filter_map(|a| first_key(*a, false))
.collect::<Vec<_>>()
.join(" / ")
};
let hint_label = actions
.iter()
.filter_map(|a| first_key(*a, true))
.collect::<Vec<_>>()
.join("/");
(label, hint_label)
}
pub(crate) fn action_for(code: KeyCode, mods: KeyModifiers) -> Option<Action> {
action_in(effective(), code, mods)
}
fn action_in(table: &[EffectiveBinding], code: KeyCode, mods: KeyModifiers) -> Option<Action> {
let mods = match code {
KeyCode::Char(_) => mods - KeyModifiers::SHIFT,
_ => mods,
};
table
.iter()
.flat_map(|b| &b.keys)
.find(|(key, m, _)| *key == code && *m == mods)
.map(|(_, _, action)| *action)
}
fn group(id: Action) -> Option<&'static EffectiveBinding> {
effective().iter().find(|b| b.id == id)
}
pub(crate) fn hint_entries() -> impl Iterator<Item = (&'static str, &'static str)> {
HINT_LAYOUT.iter().filter_map(|id| {
let binding = group(*id)?;
Some((binding.hint_label.as_str(), binding.hint?))
})
}
pub(crate) type HelpEntry = (&'static str, &'static str);
fn help_entries(layout: &'static [Action]) -> Vec<HelpEntry> {
layout
.iter()
.filter_map(|id| group(*id).map(|b| (b.label.as_str(), b.help)))
.collect()
}
pub(crate) fn help_columns() -> (Vec<HelpEntry>, Vec<HelpEntry>) {
(help_entries(HELP_LEFT), help_entries(HELP_RIGHT))
}
#[cfg(test)]
mod tests {
use super::*;
fn default_table() -> Vec<EffectiveBinding> {
build(&HashMap::new()).unwrap()
}
#[test]
fn keys_resolve_to_expected_actions() {
let t = default_table();
let f = |code| action_in(&t, code, KeyModifiers::NONE);
assert_eq!(f(KeyCode::Char('h')), Some(Action::TakeLocal));
assert_eq!(f(KeyCode::Left), Some(Action::TakeLocal));
assert_eq!(f(KeyCode::Char('U')), Some(Action::UndoFile));
assert_eq!(f(KeyCode::Char('k')), Some(Action::PrevChange));
assert_eq!(f(KeyCode::Tab), Some(Action::NextFile));
assert_eq!(f(KeyCode::Char('?')), Some(Action::Help));
assert_eq!(f(KeyCode::Char('0')), None);
assert_eq!(f(KeyCode::Esc), None);
assert_eq!(
action_in(&t, KeyCode::Char('d'), KeyModifiers::CONTROL),
Some(Action::ScrollDown)
);
assert_eq!(
action_in(&t, KeyCode::Char('u'), KeyModifiers::CONTROL),
Some(Action::ScrollUp)
);
assert_eq!(f(KeyCode::Char('d')), None);
assert_eq!(f(KeyCode::Char('u')), Some(Action::UndoChunk));
assert_eq!(
action_in(&t, KeyCode::Char('U'), KeyModifiers::SHIFT),
Some(Action::UndoFile)
);
assert_eq!(action_in(&t, KeyCode::Left, KeyModifiers::CONTROL), None);
}
#[test]
fn default_labels_match_previous_ui() {
let expected: [(&str, &str); 17] = [
("h / ←", "h"),
("l / →", "l"),
("x", "x"),
("u / U", "u/U"),
("e", "e"),
("a", "a"),
("w", "w"),
("q", "q"),
("j / k", "j/k"),
("n / p", "n/p"),
("ctrl+d / ctrl+u", "ctrl+d/ctrl+u"),
("Tab", "⇥"),
("z", "z"),
("y", "y"),
("Y", "Y"),
("H / L", "H/L"),
("?", "?"),
];
let table = default_table();
assert_eq!(table.len(), expected.len());
for (binding, (label, hint_label)) in table.iter().zip(expected) {
assert_eq!(binding.label, label, "{:?} 帮助标签漂移", binding.id);
assert_eq!(
binding.hint_label, hint_label,
"{:?} 提示条标签漂移",
binding.id
);
}
}
#[test]
fn parse_key_descriptions() {
assert_eq!(
parse_key("h"),
Some((KeyCode::Char('h'), KeyModifiers::NONE))
);
assert_eq!(
parse_key("U"),
Some((KeyCode::Char('U'), KeyModifiers::NONE))
);
assert_eq!(parse_key("LEFT"), Some((KeyCode::Left, KeyModifiers::NONE)));
assert_eq!(
parse_key("ctrl+s"),
Some((KeyCode::Char('s'), KeyModifiers::CONTROL))
);
assert_eq!(
parse_key("alt+enter"),
Some((KeyCode::Enter, KeyModifiers::ALT))
);
assert_eq!(
parse_key("shift+u"),
Some((KeyCode::Char('U'), KeyModifiers::NONE))
);
assert_eq!(parse_key("f5"), Some((KeyCode::F(5), KeyModifiers::NONE)));
assert_eq!(
parse_key("space"),
Some((KeyCode::Char(' '), KeyModifiers::NONE))
);
assert_eq!(parse_key(""), None);
assert_eq!(parse_key("ctrl+"), None);
assert_eq!(parse_key("foo"), None);
assert_eq!(parse_key("f13"), None);
assert_eq!(parse_key("meta+x"), None);
}
#[test]
fn overrides_replace_defaults_and_labels() {
let overrides = HashMap::from([
("take-local".to_owned(), "o".to_owned()),
("write".to_owned(), "ctrl+s".to_owned()),
]);
let table = build(&overrides).unwrap();
assert_eq!(
action_in(&table, KeyCode::Char('o'), KeyModifiers::NONE),
Some(Action::TakeLocal)
);
assert_eq!(
action_in(&table, KeyCode::Char('h'), KeyModifiers::NONE),
None
);
assert_eq!(action_in(&table, KeyCode::Left, KeyModifiers::NONE), None);
assert_eq!(
action_in(&table, KeyCode::Char('s'), KeyModifiers::CONTROL),
Some(Action::WriteFile)
);
let take_local = table.iter().find(|b| b.id == Action::TakeLocal).unwrap();
assert_eq!(
(take_local.label.as_str(), take_local.hint_label.as_str()),
("o", "o")
);
let write = table.iter().find(|b| b.id == Action::WriteFile).unwrap();
assert_eq!(write.label, "ctrl+s");
}
#[test]
fn partial_group_override_keeps_sibling() {
let overrides = HashMap::from([("undo".to_owned(), "ctrl+z".to_owned())]);
let table = build(&overrides).unwrap();
assert_eq!(
action_in(&table, KeyCode::Char('z'), KeyModifiers::CONTROL),
Some(Action::UndoChunk)
);
assert_eq!(
action_in(&table, KeyCode::Char('U'), KeyModifiers::NONE),
Some(Action::UndoFile)
);
let undo = table.iter().find(|b| b.id == Action::UndoChunk).unwrap();
assert_eq!(undo.label, "ctrl+z / U");
}
#[test]
fn override_errors_are_readable() {
let bad_action = HashMap::from([("no-such".to_owned(), "o".to_owned())]);
let err = build(&bad_action).unwrap_err().to_string();
assert!(err.contains("no-such") && err.contains("take-local"));
let bad_key = HashMap::from([("write".to_owned(), "ctrl+".to_owned())]);
let err = build(&bad_key).unwrap_err().to_string();
assert!(err.contains("ctrl+") && err.contains("write"));
let conflict = HashMap::from([("write".to_owned(), "h".to_owned())]);
let err = build(&conflict).unwrap_err().to_string();
assert!(err.contains('h'));
}
#[test]
fn layouts_reference_existing_groups() {
for id in HINT_LAYOUT {
let binding = group(*id).expect("提示条引用了不存在的绑定组");
assert!(binding.hint.is_some(), "{id:?} 进提示条但缺少 hint 文案");
}
for id in HELP_LEFT.iter().chain(HELP_RIGHT) {
assert!(group(*id).is_some(), "帮助浮层引用了不存在的绑定组");
}
assert_eq!(hint_entries().count(), 11);
let (left, right) = help_columns();
assert_eq!((left.len(), right.len()), (8, 9));
}
#[test]
fn group_ids_are_reachable_from_own_keys() {
for binding in BINDINGS {
assert!(
binding
.keys
.iter()
.any(|(_, _, action)| *action == binding.id),
"组 {:?} 的标识动作不在自身键位中",
binding.id
);
}
}
}