use std::collections::HashMap;
use std::fmt;
use std::fmt::Write as _;
use std::str::FromStr;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use crate::config::NavigationKeys;
use crate::project::AbsolutePath;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct KeyBind {
pub code: KeyCode,
pub modifiers: KeyModifiers,
}
impl KeyBind {
pub(crate) fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
let (code, modifiers) = match code {
KeyCode::BackTab => (code, modifiers | KeyModifiers::SHIFT),
KeyCode::Char(c)
if c.is_ascii_lowercase() && modifiers.contains(KeyModifiers::SHIFT) =>
{
(
KeyCode::Char(c.to_ascii_uppercase()),
modifiers - KeyModifiers::SHIFT,
)
},
KeyCode::Char(c) if c.is_ascii_uppercase() => (code, modifiers - KeyModifiers::SHIFT),
_ => (code, modifiers),
};
Self {
code: normalize_code(code),
modifiers,
}
}
pub(crate) fn plain(code: KeyCode) -> Self { Self::new(code, KeyModifiers::NONE) }
pub(crate) fn display(&self) -> String {
let mut parts = String::new();
if self.modifiers.contains(KeyModifiers::CONTROL) {
parts.push('⌃');
}
if self.modifiers.contains(KeyModifiers::ALT) {
parts.push('⌥');
}
if self.modifiers.contains(KeyModifiers::SHIFT) {
parts.push('⇧');
}
parts.push_str(&code_label(self.code));
parts
}
pub(crate) fn to_toml_string(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if self.modifiers.contains(KeyModifiers::CONTROL) {
parts.push("Ctrl".to_string());
}
if self.modifiers.contains(KeyModifiers::ALT) {
parts.push("Alt".to_string());
}
if self.modifiers.contains(KeyModifiers::SHIFT) {
parts.push("Shift".to_string());
}
parts.push(code_label(self.code));
parts.join("+")
}
}
impl fmt::Display for KeyBind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.display()) }
}
impl FromStr for KeyBind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> { parse_keybind(s) }
}
const fn normalize_code(code: KeyCode) -> KeyCode {
match code {
KeyCode::Char('+') => KeyCode::Char('='),
KeyCode::BackTab => KeyCode::Tab,
other => other,
}
}
fn code_label(code: KeyCode) -> String {
match code {
KeyCode::Char('=') => "+".to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Esc => "Esc".to_string(),
KeyCode::Tab | KeyCode::BackTab => "Tab".to_string(),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Delete => "Delete".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::PageUp => "PageUp".to_string(),
KeyCode::PageDown => "PageDown".to_string(),
KeyCode::F(n) => format!("F{n}"),
_ => format!("{code:?}"),
}
}
fn parse_keybind(s: &str) -> Result<KeyBind, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty key string".to_string());
}
if s == "+" || s == "=" {
return Ok(KeyBind::plain(KeyCode::Char('+')));
}
let parts: Vec<&str> = s.split('+').collect();
if parts.len() == 1 {
let code = parse_key_code(parts[0])?;
return Ok(KeyBind::new(code, KeyModifiers::NONE));
}
let (modifier_parts, key_part) = parts.split_at(parts.len() - 1);
let key_part = key_part[0];
if key_part.is_empty() {
return Err(format!("modifier with no key: \"{s}\""));
}
let mut modifiers = KeyModifiers::NONE;
for modifier in modifier_parts {
match modifier.to_lowercase().as_str() {
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"alt" | "option" => modifiers |= KeyModifiers::ALT,
"shift" => modifiers |= KeyModifiers::SHIFT,
other => return Err(format!("unknown modifier: \"{other}\"")),
}
}
let code = parse_key_code(key_part)?;
Ok(KeyBind::new(code, modifiers))
}
fn parse_key_code(s: &str) -> Result<KeyCode, String> {
match s.to_lowercase().as_str() {
"enter" | "return" => return Ok(KeyCode::Enter),
"esc" | "escape" => return Ok(KeyCode::Esc),
"tab" => return Ok(KeyCode::Tab),
"backspace" => return Ok(KeyCode::Backspace),
"delete" | "del" => return Ok(KeyCode::Delete),
"home" => return Ok(KeyCode::Home),
"end" => return Ok(KeyCode::End),
"up" => return Ok(KeyCode::Up),
"down" => return Ok(KeyCode::Down),
"left" => return Ok(KeyCode::Left),
"right" => return Ok(KeyCode::Right),
"pageup" => return Ok(KeyCode::PageUp),
"pagedown" => return Ok(KeyCode::PageDown),
"space" => return Ok(KeyCode::Char(' ')),
_ => {},
}
if let Some(n) = s.strip_prefix('F').or_else(|| s.strip_prefix('f'))
&& let Ok(n) = n.parse::<u8>()
&& (1..=12).contains(&n)
{
return Ok(KeyCode::F(n));
}
let mut chars = s.chars();
if let Some(c) = chars.next()
&& chars.next().is_none()
{
return Ok(KeyCode::Char(c));
}
Err(format!("unknown key: \"{s}\""))
}
macro_rules! action_enum {
(
$(#[$meta:meta])*
$vis:vis enum $Name:ident {
$( $Variant:ident => $toml_key:literal, $desc:literal; )*
}
) => {
$(#[$meta])*
$vis enum $Name {
$( $Variant, )*
}
impl $Name {
pub const ALL: &[Self] = &[ $( Self::$Variant, )* ];
pub const fn toml_key(self) -> &'static str {
match self {
$( Self::$Variant => $toml_key, )*
}
}
pub const fn description(self) -> &'static str {
match self {
$( Self::$Variant => $desc, )*
}
}
pub fn from_toml_key(key: &str) -> Option<Self> {
match key {
$( $toml_key => Some(Self::$Variant), )*
_ => None,
}
}
}
};
}
action_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum GlobalAction {
Quit => "quit", "Quit application";
Restart => "restart", "Restart application";
Find => "find", "Open finder";
OpenEditor => "open_editor", "Open in editor";
OpenTerminal => "open_terminal", "Open terminal";
Settings => "settings", "Open settings";
NextPane => "next_pane", "Focus next pane";
PrevPane => "prev_pane", "Focus previous pane";
OpenKeymap => "open_keymap", "Open keymap";
Rescan => "rescan", "Rescan projects";
Dismiss => "dismiss", "Dismiss focused item";
}
}
action_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ProjectListAction {
ExpandAll => "expand_all", "Expand all";
CollapseAll => "collapse_all", "Collapse all";
Clean => "clean", "Clean project";
}
}
action_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PackageAction {
Activate => "activate", "Open URL or Cargo.toml";
Clean => "clean", "Clean project";
}
}
action_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum GitAction {
Activate => "activate", "Open git URL";
Clean => "clean", "Clean project";
}
}
action_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum TargetsAction {
Activate => "activate", "Run in debug mode";
ReleaseBuild => "release_build", "Run in release mode";
Clean => "clean", "Clean project";
}
}
action_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CiRunsAction {
Activate => "activate", "Open run";
FetchMore => "fetch_more", "Fetch more CI runs";
ToggleView => "toggle_view", "Toggle branch/all filter";
ClearCache => "clear_cache", "Clear CI cache";
}
}
action_enum! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum LintsAction {
Activate => "activate", "Open lint output";
ClearHistory => "clear_history", "Clear lint history";
}
}
#[derive(Clone, Debug)]
pub(crate) struct ScopeMap<A: Copy + Eq + std::hash::Hash> {
pub by_key: HashMap<KeyBind, A>,
pub by_action: HashMap<A, KeyBind>,
}
impl<A: Copy + Eq + std::hash::Hash> ScopeMap<A> {
pub(crate) fn new() -> Self {
Self {
by_key: HashMap::new(),
by_action: HashMap::new(),
}
}
pub(crate) fn insert(&mut self, key: KeyBind, action: A) {
self.by_key.insert(key.clone(), action);
self.by_action.insert(action, key);
}
pub(crate) fn action_for(&self, key: &KeyBind) -> Option<A> { self.by_key.get(key).copied() }
pub(crate) fn key_for(&self, action: A) -> Option<&KeyBind> { self.by_action.get(&action) }
pub(crate) fn display_key_for(&self, action: A) -> String {
self.key_for(action)
.map_or_else(|| "—".to_string(), KeyBind::display)
}
}
impl<A: Copy + Eq + std::hash::Hash> Default for ScopeMap<A> {
fn default() -> Self { Self::new() }
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ResolvedKeymap {
pub global: ScopeMap<GlobalAction>,
pub project_list: ScopeMap<ProjectListAction>,
pub package: ScopeMap<PackageAction>,
pub git: ScopeMap<GitAction>,
pub targets: ScopeMap<TargetsAction>,
pub ci_runs: ScopeMap<CiRunsAction>,
pub lints: ScopeMap<LintsAction>,
}
impl ResolvedKeymap {
pub(crate) fn defaults() -> Self {
let mut km = Self::default();
km.global
.insert(KeyBind::plain(KeyCode::Char('q')), GlobalAction::Quit);
km.global
.insert(KeyBind::plain(KeyCode::Char('R')), GlobalAction::Restart);
km.global
.insert(KeyBind::plain(KeyCode::Char('/')), GlobalAction::Find);
km.global
.insert(KeyBind::plain(KeyCode::Char('e')), GlobalAction::OpenEditor);
km.global.insert(
KeyBind::plain(KeyCode::Char('t')),
GlobalAction::OpenTerminal,
);
km.global
.insert(KeyBind::plain(KeyCode::Char('s')), GlobalAction::Settings);
km.global
.insert(KeyBind::plain(KeyCode::Tab), GlobalAction::NextPane);
km.global.insert(
KeyBind::new(KeyCode::BackTab, KeyModifiers::SHIFT),
GlobalAction::PrevPane,
);
km.global.insert(
KeyBind::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
GlobalAction::OpenKeymap,
);
km.global.insert(
KeyBind::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
GlobalAction::Rescan,
);
km.global
.insert(KeyBind::plain(KeyCode::Char('x')), GlobalAction::Dismiss);
km.project_list.insert(
KeyBind::plain(KeyCode::Char('=')),
ProjectListAction::ExpandAll,
);
km.project_list.insert(
KeyBind::plain(KeyCode::Char('-')),
ProjectListAction::CollapseAll,
);
km.project_list
.insert(KeyBind::plain(KeyCode::Char('c')), ProjectListAction::Clean);
km.package
.insert(KeyBind::plain(KeyCode::Enter), PackageAction::Activate);
km.package
.insert(KeyBind::plain(KeyCode::Char('c')), PackageAction::Clean);
km.git
.insert(KeyBind::plain(KeyCode::Enter), GitAction::Activate);
km.git
.insert(KeyBind::plain(KeyCode::Char('c')), GitAction::Clean);
km.targets
.insert(KeyBind::plain(KeyCode::Enter), TargetsAction::Activate);
km.targets.insert(
KeyBind::plain(KeyCode::Char('r')),
TargetsAction::ReleaseBuild,
);
km.targets
.insert(KeyBind::plain(KeyCode::Char('c')), TargetsAction::Clean);
km.ci_runs
.insert(KeyBind::plain(KeyCode::Enter), CiRunsAction::Activate);
km.ci_runs
.insert(KeyBind::plain(KeyCode::Char('f')), CiRunsAction::FetchMore);
km.ci_runs
.insert(KeyBind::plain(KeyCode::Char('v')), CiRunsAction::ToggleView);
km.ci_runs
.insert(KeyBind::plain(KeyCode::Char('d')), CiRunsAction::ClearCache);
km.lints
.insert(KeyBind::plain(KeyCode::Enter), LintsAction::Activate);
km.lints.insert(
KeyBind::plain(KeyCode::Char('d')),
LintsAction::ClearHistory,
);
km
}
fn write_scope<A: Copy + Eq + std::hash::Hash>(
out: &mut String,
header: &str,
scope: &ScopeMap<A>,
actions: &[A],
toml_key: fn(A) -> &'static str,
) {
let _ = writeln!(out, "[{header}]");
let mut entries: Vec<(&str, String)> = actions
.iter()
.map(|&action| {
let key_str = scope
.key_for(action)
.map_or_else(String::new, KeyBind::to_toml_string);
(toml_key(action), key_str)
})
.collect();
entries.sort_by_key(|(name, _)| *name);
let max_len = entries
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or(0);
for (name, value) in &entries {
let _ = writeln!(out, "{name:<max_len$} = \"{value}\"");
}
out.push('\n');
}
pub(crate) fn default_toml() -> String {
let km = Self::defaults();
let mut out = String::from(
"# cargo-port keymap configuration\n\
# Edit bindings below. Format: action = \"Key\" or \"Modifier+Key\"\n\
# Modifiers: Ctrl, Alt, Shift. Examples: \"Ctrl+r\", \"Shift+Tab\", \"q\"\n\
# Note: = and + are treated as the same physical key.\n\
# Note: when vim navigation is enabled, h/j/k/l are reserved\n\
# for navigation and cannot be used as action keys.\n\n",
);
Self::write_all_scopes(&mut out, &km);
out
}
pub(crate) fn default_toml_from(km: &Self) -> String {
let mut out = String::new();
Self::write_all_scopes(&mut out, km);
out
}
fn write_all_scopes(out: &mut String, km: &Self) {
Self::write_scope(
out,
"global",
&km.global,
GlobalAction::ALL,
GlobalAction::toml_key,
);
Self::write_scope(
out,
"project_list",
&km.project_list,
ProjectListAction::ALL,
ProjectListAction::toml_key,
);
Self::write_scope(
out,
"package",
&km.package,
PackageAction::ALL,
PackageAction::toml_key,
);
Self::write_scope(out, "git", &km.git, GitAction::ALL, GitAction::toml_key);
Self::write_scope(
out,
"targets",
&km.targets,
TargetsAction::ALL,
TargetsAction::toml_key,
);
Self::write_scope(
out,
"ci_runs",
&km.ci_runs,
CiRunsAction::ALL,
CiRunsAction::toml_key,
);
Self::write_scope(
out,
"lints",
&km.lints,
LintsAction::ALL,
LintsAction::toml_key,
);
}
}
pub(crate) struct KeymapLoadResult {
pub keymap: ResolvedKeymap,
pub errors: Vec<KeymapError>,
pub missing_actions: Vec<String>,
}
pub(crate) struct KeymapError {
pub scope: String,
pub action: String,
pub key: String,
pub reason: KeymapErrorReason,
}
impl fmt::Display for KeymapError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}.{}: \"{}\" — {}",
self.scope, self.action, self.key, self.reason
)
}
}
pub(crate) enum KeymapErrorReason {
ParseError(String),
ConflictWithGlobal(String),
ConflictWithinScope(String),
ReservedForVimMode,
ReservedForNavigation,
UnknownAction,
}
impl fmt::Display for KeymapErrorReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ParseError(msg) => write!(f, "parse error: {msg}"),
Self::ConflictWithGlobal(action) => write!(f, "conflicts with global.{action}"),
Self::ConflictWithinScope(action) => write!(f, "conflicts with {action}"),
Self::ReservedForVimMode => write!(f, "reserved for vim navigation"),
Self::ReservedForNavigation => write!(f, "reserved for navigation"),
Self::UnknownAction => write!(f, "unknown action (ignored)"),
}
}
}
pub(crate) fn keymap_path() -> Option<AbsolutePath> {
dirs::config_dir().map(|d| {
d.join(crate::constants::APP_NAME)
.join(crate::constants::KEYMAP_FILE)
.into()
})
}
pub(crate) fn load_keymap(vim_mode: NavigationKeys) -> KeymapLoadResult {
let Some(path) = keymap_path() else {
return KeymapLoadResult {
keymap: ResolvedKeymap::defaults(),
errors: Vec::new(),
missing_actions: Vec::new(),
};
};
if !path.exists() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, ResolvedKeymap::default_toml());
return KeymapLoadResult {
keymap: ResolvedKeymap::defaults(),
errors: Vec::new(),
missing_actions: Vec::new(),
};
}
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
return KeymapLoadResult {
keymap: ResolvedKeymap::defaults(),
errors: vec![KeymapError {
scope: String::new(),
action: String::new(),
key: String::new(),
reason: KeymapErrorReason::ParseError(format!("read error: {e}")),
}],
missing_actions: Vec::new(),
};
},
};
let table: toml::Table = match contents.parse() {
Ok(t) => t,
Err(e) => {
return KeymapLoadResult {
keymap: ResolvedKeymap::defaults(),
errors: vec![KeymapError {
scope: String::new(),
action: String::new(),
key: String::new(),
reason: KeymapErrorReason::ParseError(format!("TOML parse error: {e}")),
}],
missing_actions: Vec::new(),
};
},
};
let result = resolve_from_table(&table, vim_mode);
if !result.missing_actions.is_empty() {
let content = ResolvedKeymap::default_toml_from(&result.keymap);
let _ = std::fs::write(&path, content);
}
result
}
pub(crate) fn load_keymap_from_str(toml_str: &str, vim_mode: NavigationKeys) -> KeymapLoadResult {
let table: toml::Table = match toml_str.parse() {
Ok(t) => t,
Err(e) => {
return KeymapLoadResult {
keymap: ResolvedKeymap::defaults(),
errors: vec![KeymapError {
scope: String::new(),
action: String::new(),
key: String::new(),
reason: KeymapErrorReason::ParseError(format!("TOML parse error: {e}")),
}],
missing_actions: Vec::new(),
};
},
};
resolve_from_table(&table, vim_mode)
}
pub(crate) fn vim_mode_conflicts(keymap: &ResolvedKeymap) -> Vec<String> {
fn check_scope<A: Copy + Eq + std::hash::Hash>(
scope_name: &str,
scope: &ScopeMap<A>,
vim_keys: &[KeyCode; 4],
toml_key: fn(A) -> &'static str,
conflicts: &mut Vec<String>,
) {
for (bind, &action) in &scope.by_key {
if bind.modifiers == KeyModifiers::NONE && vim_keys.contains(&bind.code) {
conflicts.push(format!("{scope_name}.{}", toml_key(action)));
}
}
}
let vim_keys: [KeyCode; 4] = [
KeyCode::Char('h'),
KeyCode::Char('j'),
KeyCode::Char('k'),
KeyCode::Char('l'),
];
let mut conflicts = Vec::new();
check_scope(
"global",
&keymap.global,
&vim_keys,
GlobalAction::toml_key,
&mut conflicts,
);
check_scope(
"project_list",
&keymap.project_list,
&vim_keys,
ProjectListAction::toml_key,
&mut conflicts,
);
check_scope(
"package",
&keymap.package,
&vim_keys,
PackageAction::toml_key,
&mut conflicts,
);
check_scope(
"git",
&keymap.git,
&vim_keys,
GitAction::toml_key,
&mut conflicts,
);
check_scope(
"targets",
&keymap.targets,
&vim_keys,
TargetsAction::toml_key,
&mut conflicts,
);
check_scope(
"ci_runs",
&keymap.ci_runs,
&vim_keys,
CiRunsAction::toml_key,
&mut conflicts,
);
check_scope(
"lints",
&keymap.lints,
&vim_keys,
LintsAction::toml_key,
&mut conflicts,
);
conflicts
}
const VIM_RESERVED: [KeyCode; 4] = [
KeyCode::Char('h'),
KeyCode::Char('j'),
KeyCode::Char('k'),
KeyCode::Char('l'),
];
const NAVIGATION_RESERVED: [KeyCode; 6] = [
KeyCode::Up,
KeyCode::Down,
KeyCode::Left,
KeyCode::Right,
KeyCode::Home,
KeyCode::End,
];
fn is_vim_reserved(bind: &KeyBind, vim_mode: NavigationKeys) -> bool {
vim_mode.uses_vim() && bind.modifiers == KeyModifiers::NONE && VIM_RESERVED.contains(&bind.code)
}
fn is_navigation_reserved(bind: &KeyBind) -> bool {
bind.modifiers == KeyModifiers::NONE && NAVIGATION_RESERVED.contains(&bind.code)
}
fn is_legacy_removed_action(scope_name: &str, action: &str) -> bool {
scope_name == "project_list" && matches!(action, "open_editor" | "rescan")
}
fn resolve_from_table(table: &toml::Table, vim_mode: NavigationKeys) -> KeymapLoadResult {
let defaults = ResolvedKeymap::defaults();
let mut keymap = ResolvedKeymap::default();
let mut errors = Vec::new();
let mut missing_actions = Vec::new();
let no_globals = HashMap::new();
let mut ctx = ScopeResolveContext {
table,
errors: &mut errors,
missing_actions: &mut missing_actions,
global_keys: &no_globals,
vim_mode,
};
resolve_scope(
&mut ctx,
"global",
GlobalAction::ALL,
GlobalAction::from_toml_key,
GlobalAction::toml_key,
&defaults.global,
&mut keymap.global,
);
let global_keys: HashMap<KeyBind, String> = keymap
.global
.by_key
.iter()
.map(|(k, &a)| (k.clone(), a.toml_key().to_string()))
.collect();
ctx.global_keys = &global_keys;
resolve_pane_scopes(&mut ctx, &defaults, &mut keymap);
KeymapLoadResult {
keymap,
errors,
missing_actions,
}
}
fn resolve_pane_scopes(
ctx: &mut ScopeResolveContext<'_>,
defaults: &ResolvedKeymap,
keymap: &mut ResolvedKeymap,
) {
resolve_scope(
ctx,
"project_list",
ProjectListAction::ALL,
ProjectListAction::from_toml_key,
ProjectListAction::toml_key,
&defaults.project_list,
&mut keymap.project_list,
);
resolve_scope(
ctx,
"package",
PackageAction::ALL,
PackageAction::from_toml_key,
PackageAction::toml_key,
&defaults.package,
&mut keymap.package,
);
resolve_scope(
ctx,
"git",
GitAction::ALL,
GitAction::from_toml_key,
GitAction::toml_key,
&defaults.git,
&mut keymap.git,
);
resolve_scope(
ctx,
"targets",
TargetsAction::ALL,
TargetsAction::from_toml_key,
TargetsAction::toml_key,
&defaults.targets,
&mut keymap.targets,
);
resolve_scope(
ctx,
"ci_runs",
CiRunsAction::ALL,
CiRunsAction::from_toml_key,
CiRunsAction::toml_key,
&defaults.ci_runs,
&mut keymap.ci_runs,
);
resolve_scope(
ctx,
"lints",
LintsAction::ALL,
LintsAction::from_toml_key,
LintsAction::toml_key,
&defaults.lints,
&mut keymap.lints,
);
}
struct ScopeResolveContext<'a> {
table: &'a toml::Table,
errors: &'a mut Vec<KeymapError>,
missing_actions: &'a mut Vec<String>,
global_keys: &'a HashMap<KeyBind, String>,
vim_mode: NavigationKeys,
}
fn resolve_scope<A: Copy + Eq + std::hash::Hash>(
ctx: &mut ScopeResolveContext<'_>,
scope_name: &str,
all_actions: &[A],
from_toml_key: fn(&str) -> Option<A>,
to_toml_key: fn(A) -> &'static str,
defaults: &ScopeMap<A>,
target: &mut ScopeMap<A>,
) {
let scope_table = ctx.table.get(scope_name).and_then(toml::Value::as_table);
if let Some(st) = scope_table {
for key in st.keys() {
if from_toml_key(key).is_none() && !is_legacy_removed_action(scope_name, key) {
ctx.errors.push(KeymapError {
scope: scope_name.to_string(),
action: key.clone(),
key: String::new(),
reason: KeymapErrorReason::UnknownAction,
});
}
}
}
for &action in all_actions {
let toml_key = to_toml_key(action);
let raw_value = scope_table
.and_then(|st| st.get(toml_key))
.and_then(toml::Value::as_str);
let bind_result = raw_value.map(str::parse::<KeyBind>);
let (bind, error) = match bind_result {
Some(Ok(bind)) => {
if is_navigation_reserved(&bind) {
(None, Some(KeymapErrorReason::ReservedForNavigation))
} else if is_vim_reserved(&bind, ctx.vim_mode) {
(None, Some(KeymapErrorReason::ReservedForVimMode))
} else if let Some(global_action) = ctx.global_keys.get(&bind) {
(
None,
Some(KeymapErrorReason::ConflictWithGlobal(global_action.clone())),
)
} else if let Some(&existing) = target.by_key.get(&bind) {
(
None,
Some(KeymapErrorReason::ConflictWithinScope(
to_toml_key(existing).to_string(),
)),
)
} else {
(Some(bind), None)
}
},
Some(Err(e)) => (None, Some(KeymapErrorReason::ParseError(e))),
None => {
ctx.missing_actions.push(format!("{scope_name}.{toml_key}"));
(None, None)
},
};
if let Some(reason) = error {
ctx.errors.push(KeymapError {
scope: scope_name.to_string(),
action: toml_key.to_string(),
key: raw_value.unwrap_or("").to_string(),
reason,
});
}
if let Some(bind) = bind {
target.insert(bind, action);
} else {
if let Some(default_bind) = defaults.key_for(action) {
target.insert(default_bind.clone(), action);
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use super::*;
fn normalize_snapshot(text: &str) -> String {
let normalized = text.replace("\r\n", "\n");
normalized.trim_end_matches(['\r', '\n']).to_string()
}
#[test]
fn parse_plain_char() {
let kb: KeyBind = "q".parse().unwrap();
assert_eq!(kb.code, KeyCode::Char('q'));
assert_eq!(kb.modifiers, KeyModifiers::NONE);
}
#[test]
fn parse_named_keys() {
assert_eq!("Enter".parse::<KeyBind>().unwrap().code, KeyCode::Enter);
assert_eq!("Esc".parse::<KeyBind>().unwrap().code, KeyCode::Esc);
assert_eq!("Tab".parse::<KeyBind>().unwrap().code, KeyCode::Tab);
assert_eq!("Space".parse::<KeyBind>().unwrap().code, KeyCode::Char(' '));
assert_eq!("F1".parse::<KeyBind>().unwrap().code, KeyCode::F(1));
assert_eq!("F12".parse::<KeyBind>().unwrap().code, KeyCode::F(12));
}
#[test]
fn parse_ctrl_modifier() {
let kb: KeyBind = "Ctrl+r".parse().unwrap();
assert_eq!(kb.code, KeyCode::Char('r'));
assert!(kb.modifiers.contains(KeyModifiers::CONTROL));
}
#[test]
fn parse_shift_modifier() {
let kb: KeyBind = "Shift+Tab".parse().unwrap();
assert_eq!(kb.code, KeyCode::Tab);
assert!(kb.modifiers.contains(KeyModifiers::SHIFT));
}
#[test]
fn parse_alt_modifier() {
let kb: KeyBind = "Alt+d".parse().unwrap();
assert_eq!(kb.code, KeyCode::Char('d'));
assert!(kb.modifiers.contains(KeyModifiers::ALT));
}
#[test]
fn parse_multiple_modifiers() {
let kb: KeyBind = "Ctrl+Shift+x".parse().unwrap();
assert_eq!(kb.code, KeyCode::Char('X'));
assert!(kb.modifiers.contains(KeyModifiers::CONTROL));
assert!(!kb.modifiers.contains(KeyModifiers::SHIFT));
}
#[test]
fn serde_round_trip() {
let cases = [
"q",
"Ctrl+r",
"Alt+d",
"Shift+Tab",
"Enter",
"Esc",
"/",
"-",
];
for input in cases {
let kb: KeyBind = input.parse().unwrap();
let serialized = kb.to_toml_string();
let reparsed: KeyBind = serialized.parse().unwrap();
assert_eq!(kb, reparsed, "round-trip failed for \"{input}\"");
}
}
#[test]
fn equals_plus_normalization() {
let plus: KeyBind = "+".parse().unwrap();
let equals: KeyBind = "=".parse().unwrap();
assert_eq!(plus, equals);
}
#[test]
fn uppercase_char_strips_shift() {
let from_event = KeyBind::new(KeyCode::Char('R'), KeyModifiers::SHIFT);
let from_toml = KeyBind::plain(KeyCode::Char('R'));
assert_eq!(from_event, from_toml);
assert_eq!(from_event.modifiers, KeyModifiers::NONE);
}
#[test]
fn shift_plus_lowercase_becomes_uppercase() {
let shift_r: KeyBind = "Shift+r".parse().unwrap();
let bare_r: KeyBind = "R".parse().unwrap();
assert_eq!(shift_r, bare_r);
assert_eq!(shift_r.code, KeyCode::Char('R'));
assert_eq!(shift_r.modifiers, KeyModifiers::NONE);
}
#[test]
fn ctrl_shift_letter_keeps_ctrl() {
let kb = KeyBind::new(
KeyCode::Char('r'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(kb.code, KeyCode::Char('R'));
assert!(kb.modifiers.contains(KeyModifiers::CONTROL));
assert!(!kb.modifiers.contains(KeyModifiers::SHIFT));
}
#[test]
fn lowercase_without_shift_unchanged() {
let kb = KeyBind::plain(KeyCode::Char('r'));
assert_eq!(kb.code, KeyCode::Char('r'));
assert_eq!(kb.modifiers, KeyModifiers::NONE);
}
#[test]
fn restart_default_matches_crossterm_event() {
let default_bind = ResolvedKeymap::defaults()
.global
.key_for(GlobalAction::Restart)
.unwrap()
.clone();
let crossterm_event = KeyBind::new(KeyCode::Char('R'), KeyModifiers::SHIFT);
assert_eq!(default_bind, crossterm_event);
}
#[test]
fn display_glyphs() {
assert_eq!(
KeyBind::new(KeyCode::Char('r'), KeyModifiers::CONTROL).display(),
"⌃r"
);
assert_eq!(
KeyBind::new(KeyCode::Char('d'), KeyModifiers::ALT).display(),
"⌥d"
);
assert_eq!(
KeyBind::new(KeyCode::Tab, KeyModifiers::SHIFT).display(),
"⇧Tab"
);
assert_eq!(KeyBind::plain(KeyCode::Char('q')).display(), "q");
}
#[test]
fn plus_displays_as_plus() {
let kb = KeyBind::plain(KeyCode::Char('='));
assert_eq!(kb.display(), "+");
assert_eq!(kb.to_toml_string(), "+");
}
#[test]
fn parse_errors() {
assert!("".parse::<KeyBind>().is_err(), "empty string");
assert!("Ctrl+".parse::<KeyBind>().is_err(), "modifier with no key");
assert!("Ctrl+Ctrl".parse::<KeyBind>().is_err(), "modifier as key");
}
#[test]
fn valid_edge_cases() {
assert!("+".parse::<KeyBind>().is_ok(), "plus key");
assert!("/".parse::<KeyBind>().is_ok(), "slash key");
assert!("Space".parse::<KeyBind>().is_ok(), "space key");
}
#[test]
fn defaults_scope_map_consistency() {
fn check<A: Copy + Eq + std::hash::Hash>(scope: &ScopeMap<A>, actions: &[A]) {
for &action in actions {
assert!(
scope.key_for(action).is_some(),
"action missing from by_action"
);
}
for (key, &action) in &scope.by_key {
assert_eq!(
scope.by_action.get(&action),
Some(key),
"by_key/by_action mismatch"
);
}
assert_eq!(scope.by_key.len(), scope.by_action.len());
}
let km = ResolvedKeymap::defaults();
check(&km.global, GlobalAction::ALL);
check(&km.project_list, ProjectListAction::ALL);
check(&km.package, PackageAction::ALL);
check(&km.git, GitAction::ALL);
check(&km.targets, TargetsAction::ALL);
check(&km.ci_runs, CiRunsAction::ALL);
check(&km.lints, LintsAction::ALL);
}
#[test]
fn default_toml_is_parseable() {
let toml_str = ResolvedKeymap::default_toml();
let table: toml::Table = toml_str.parse().unwrap();
assert!(table.contains_key("global"));
assert!(table.contains_key("project_list"));
assert!(table.contains_key("package"));
assert!(table.contains_key("git"));
assert!(table.contains_key("targets"));
assert!(table.contains_key("ci_runs"));
assert!(table.contains_key("lints"));
}
#[test]
fn default_toml_loads_without_errors() {
let toml_str = ResolvedKeymap::default_toml();
let result = load_keymap_from_str(&toml_str, NavigationKeys::ArrowsOnly);
assert!(
result.errors.is_empty(),
"errors: {:?}",
result
.errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
);
}
#[test]
fn global_global_conflict_detected() {
let toml = r#"
[global]
quit = "q"
restart = "q"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert!(
result
.errors
.iter()
.any(|e| matches!(e.reason, KeymapErrorReason::ConflictWithinScope(_))),
"expected intra-scope conflict for duplicate 'q'"
);
}
#[test]
fn pane_global_conflict_detected() {
let toml = r#"
[global]
quit = "q"
restart = "Shift+r"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
[project_list]
clean = "q"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert!(
result
.errors
.iter()
.any(|e| matches!(e.reason, KeymapErrorReason::ConflictWithGlobal(_))),
"expected conflict with global 'q'"
);
}
#[test]
fn cross_scope_same_key_is_ok() {
let toml = r#"
[global]
quit = "q"
restart = "Shift+r"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
[project_list]
clean = "c"
[ci_runs]
clear_cache = "d"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert!(
!result
.errors
.iter()
.any(|e| !matches!(e.reason, KeymapErrorReason::UnknownAction)),
"unexpected errors"
);
}
#[test]
fn vim_mode_reservation() {
let toml = r#"
[global]
quit = "q"
restart = "Shift+r"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
[project_list]
clean = "h"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsAndVim);
assert!(
result
.errors
.iter()
.any(|e| matches!(e.reason, KeymapErrorReason::ReservedForVimMode)),
"expected vim reservation error for 'h'"
);
}
#[test]
fn navigation_key_reserved() {
let toml = r#"
[global]
quit = "Up"
restart = "Shift+r"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert!(
result
.errors
.iter()
.any(|e| matches!(e.reason, KeymapErrorReason::ReservedForNavigation)),
"expected navigation reservation error for 'Up'"
);
}
#[test]
fn navigation_key_with_modifier_allowed() {
let toml = r#"
[global]
quit = "Ctrl+Up"
restart = "Shift+r"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert!(
!result
.errors
.iter()
.any(|e| matches!(e.reason, KeymapErrorReason::ReservedForNavigation)),
"Ctrl+Up should be allowed"
);
}
#[test]
fn vim_mode_allows_modified_hjkl() {
let toml = r#"
[global]
quit = "q"
restart = "Shift+r"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
focus_list = "Esc"
open_keymap = "Ctrl+h"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsAndVim);
assert!(
!result
.errors
.iter()
.any(|e| matches!(e.reason, KeymapErrorReason::ReservedForVimMode)),
"Ctrl+h should be allowed even with vim mode"
);
}
#[test]
fn unknown_action_reported() {
let toml = r#"
[project_list]
claen = "c"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
let unknown: Vec<_> = result
.errors
.iter()
.filter(|e| matches!(e.reason, KeymapErrorReason::UnknownAction))
.collect();
assert!(
!unknown.is_empty(),
"expected unknown action for typo 'claen'"
);
assert_eq!(unknown[0].action, "claen");
}
#[test]
fn legacy_project_list_open_editor_is_ignored() {
let toml = r#"
[global]
quit = "q"
restart = "R"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
rescan = "r"
dismiss = "x"
[project_list]
open_editor = "Enter"
expand_all = "="
collapse_all = "-"
clean = "c"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert!(
result
.errors
.iter()
.all(|e| !matches!(e.reason, KeymapErrorReason::UnknownAction)),
"legacy project_list.open_editor should not be reported as unknown: {:?}",
result
.errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
);
assert!(
result
.missing_actions
.iter()
.any(|action| action == "global.open_editor"),
"new global.open_editor should be backfilled"
);
assert_eq!(
result.keymap.global.key_for(GlobalAction::OpenEditor),
Some(&KeyBind::plain(KeyCode::Char('e')))
);
}
#[test]
fn partial_acceptance_valid_bindings_applied() {
let toml = r#"
[global]
quit = "x"
restart = "x"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert_eq!(
result.keymap.global.key_for(GlobalAction::Quit),
Some(&KeyBind::plain(KeyCode::Char('x')))
);
assert!(
result
.keymap
.global
.key_for(GlobalAction::Restart)
.is_some(),
"restart should have a fallback binding"
);
assert!(!result.errors.is_empty());
}
#[test]
fn malformed_toml_returns_defaults() {
let result = load_keymap_from_str("{{invalid toml", NavigationKeys::ArrowsOnly);
assert!(!result.errors.is_empty());
assert!(result.keymap.global.key_for(GlobalAction::Quit).is_some());
}
#[test]
fn vim_mode_conflicts_detected() {
let defaults = ResolvedKeymap::defaults();
let conflicts = vim_mode_conflicts(&defaults);
assert!(conflicts.is_empty());
let toml = r#"
[global]
quit = "q"
restart = "Shift+r"
find = "/"
settings = "h"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
let conflicts = vim_mode_conflicts(&result.keymap);
assert!(!conflicts.is_empty(), "expected conflict for 'h' binding");
}
#[test]
fn action_description_and_display_key() {
assert_eq!(GlobalAction::Quit.description(), "Quit application");
let km = ResolvedKeymap::defaults();
assert_eq!(km.global.display_key_for(GlobalAction::Quit), "q");
assert_eq!(km.global.display_key_for(GlobalAction::OpenEditor), "e");
assert_eq!(km.global.display_key_for(GlobalAction::OpenTerminal), "t");
assert_eq!(km.ci_runs.display_key_for(CiRunsAction::ToggleView), "v");
assert_eq!(km.global.display_key_for(GlobalAction::OpenKeymap), "⌃k");
}
#[test]
fn legacy_ci_runs_t_conflicts_with_global_terminal_and_falls_back_to_v() {
let toml = r#"
[global]
quit = "q"
restart = "R"
find = "/"
open_editor = "e"
open_terminal = "t"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
dismiss = "x"
open_keymap = "Ctrl+k"
[ci_runs]
activate = "Enter"
toggle_view = "t"
clear_cache = "d"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert!(
result.errors.iter().any(|error| {
error.scope == "ci_runs"
&& error.action == "toggle_view"
&& matches!(error.reason, KeymapErrorReason::ConflictWithGlobal(_))
}),
"expected ci_runs.toggle_view conflict with global terminal"
);
assert_eq!(
result.keymap.global.key_for(GlobalAction::OpenTerminal),
Some(&KeyBind::plain(KeyCode::Char('t')))
);
assert_eq!(
result.keymap.ci_runs.key_for(CiRunsAction::ToggleView),
Some(&KeyBind::plain(KeyCode::Char('v')))
);
}
#[test]
fn missing_action_detected() {
let toml = r#"
[global]
restart = "R"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
open_keymap = "Ctrl+k"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert!(
result.missing_actions.iter().any(|m| m == "global.quit"),
"expected global.quit in missing_actions: {:?}",
result.missing_actions
);
assert_eq!(
result.keymap.global.key_for(GlobalAction::Quit),
Some(&KeyBind::plain(KeyCode::Char('q')))
);
}
#[test]
fn complete_keymap_has_no_missing() {
let toml_str = ResolvedKeymap::default_toml();
let result = load_keymap_from_str(&toml_str, NavigationKeys::ArrowsOnly);
assert!(
result.missing_actions.is_empty(),
"default toml should have no missing actions: {:?}",
result.missing_actions
);
}
#[test]
fn default_keymap_template_matches_golden_file() {
let generated = ResolvedKeymap::default_toml();
let expected = include_str!("../tests/assets/default-keymap.toml");
assert_eq!(normalize_snapshot(&generated), normalize_snapshot(expected));
}
#[test]
fn missing_entire_scope_detected() {
let toml = r#"
[global]
quit = "q"
restart = "R"
find = "/"
settings = "s"
next_pane = "Tab"
prev_pane = "Shift+Tab"
dismiss = "x"
open_keymap = "Ctrl+k"
"#;
let result = load_keymap_from_str(toml, NavigationKeys::ArrowsOnly);
assert!(
result
.missing_actions
.iter()
.any(|m| m.starts_with("lints.")),
"expected lints actions in missing: {:?}",
result.missing_actions
);
}
}