#![allow(dead_code)]
mod defaults;
mod parser;
use crossterm::event::KeyEvent;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
pub use parser::{KeyBinding, parse_key};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Context {
Global,
Leader,
LeaderBuffer,
LeaderWindow,
LeaderFile,
LeaderQuit,
LeaderSnippet,
Sidebar,
Scripts,
Grid,
Oil,
Overlay,
}
impl Context {
pub fn as_str(self) -> &'static str {
match self {
Context::Global => "global",
Context::Leader => "leader",
Context::LeaderBuffer => "leader_buffer",
Context::LeaderWindow => "leader_window",
Context::LeaderFile => "leader_file",
Context::LeaderQuit => "leader_quit",
Context::LeaderSnippet => "leader_snippet",
Context::Sidebar => "sidebar",
Context::Scripts => "scripts",
Context::Grid => "grid",
Context::Oil => "oil",
Context::Overlay => "overlay",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct KeyBindings {
map: HashMap<&'static str, HashMap<String, Vec<KeyBinding>>>,
string_map: HashMap<&'static str, HashMap<String, Vec<String>>>,
}
impl KeyBindings {
pub fn defaults() -> Self {
let raw = defaults::defaults();
Self::from_string_map(raw)
}
pub fn load_from_default_path() -> (Self, Option<String>) {
let mut bindings = Self::defaults();
let path = match config_path() {
Some(p) => p,
None => return (bindings, None),
};
if !path.exists() {
return (bindings, None);
}
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => return (bindings, Some(format!("read failed: {e}"))),
};
match toml::from_str::<BTreeMap<String, BTreeMap<String, toml::Value>>>(&content) {
Ok(parsed) => {
if let Err(e) = bindings.merge_overrides(parsed) {
return (bindings, Some(e));
}
(bindings, None)
}
Err(e) => (bindings, Some(format!("syntax error: {e}"))),
}
}
fn from_string_map(raw: BTreeMap<String, BTreeMap<String, Vec<String>>>) -> Self {
let mut map: HashMap<&'static str, HashMap<String, Vec<KeyBinding>>> = HashMap::new();
let mut string_map: HashMap<&'static str, HashMap<String, Vec<String>>> = HashMap::new();
for (ctx_name, actions) in raw {
let ctx_static = match static_ctx_name(&ctx_name) {
Some(s) => s,
None => continue, };
let mut action_map: HashMap<String, Vec<KeyBinding>> = HashMap::new();
let mut action_strings: HashMap<String, Vec<String>> = HashMap::new();
for (action, keys) in actions {
let parsed: Vec<KeyBinding> =
keys.iter().filter_map(|k| parse_key(k).ok()).collect();
action_map.insert(action.clone(), parsed);
action_strings.insert(action, keys);
}
map.insert(ctx_static, action_map);
string_map.insert(ctx_static, action_strings);
}
Self { map, string_map }
}
fn merge_overrides(
&mut self,
overrides: BTreeMap<String, BTreeMap<String, toml::Value>>,
) -> Result<(), String> {
for (ctx_name, actions) in overrides {
let ctx_static = match static_ctx_name(&ctx_name) {
Some(s) => s,
None => continue,
};
let action_map = self.map.entry(ctx_static).or_default();
let action_strings = self.string_map.entry(ctx_static).or_default();
for (action, value) in actions {
let key_strings: Vec<String> = match value {
toml::Value::String(s) => vec![s],
toml::Value::Array(arr) => arr
.into_iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => continue,
};
let mut parsed = Vec::new();
for s in &key_strings {
match parse_key(s) {
Ok(kb) => parsed.push(kb),
Err(e) => return Err(format!("[{ctx_name}].{action}: {e}")),
}
}
action_map.insert(action.clone(), parsed);
action_strings.insert(action, key_strings);
}
}
Ok(())
}
pub fn matches(&self, context: Context, action: &str, ev: &KeyEvent) -> bool {
if let Some(action_map) = self.map.get(context.as_str())
&& let Some(bindings) = action_map.get(action)
{
for kb in bindings {
if kb.matches(ev.code, ev.modifiers) {
return true;
}
}
}
false
}
pub fn keys_for(&self, context: Context, action: &str) -> Vec<String> {
self.string_map
.get(context.as_str())
.and_then(|m| m.get(action))
.cloned()
.unwrap_or_default()
}
pub fn primary_key(&self, context: Context, action: &str) -> String {
self.keys_for(context, action)
.into_iter()
.next()
.unwrap_or_else(|| "?".to_string())
}
pub fn to_toml(&self) -> String {
let mut out = String::new();
out.push_str("# dbtui keybindings — generated by `dbtui --dump-keybindings`.\n");
out.push_str("# Edit and save to ~/.config/dbtui/keybindings.toml.\n");
out.push_str("# You only need the keys you want to change — anything you omit\n");
out.push_str("# falls back to the default below.\n");
out.push_str("# See KEYBINDINGS.md for the format.\n\n");
let order = [
"global",
"leader",
"leader_buffer",
"leader_window",
"leader_file",
"leader_quit",
"leader_snippet",
"sidebar",
"scripts",
"grid",
"oil",
"overlay",
];
for ctx in order {
let actions = match self.string_map.get(ctx) {
Some(a) => a,
None => continue,
};
out.push_str(&format!("[{ctx}]\n"));
let mut entries: Vec<(&String, &Vec<String>)> = actions.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
for (action, keys) in entries {
if keys.len() == 1 {
out.push_str(&format!("{action} = {:?}\n", keys[0]));
} else {
let formatted: Vec<String> = keys.iter().map(|k| format!("{k:?}")).collect();
out.push_str(&format!("{action} = [{}]\n", formatted.join(", ")));
}
}
out.push('\n');
}
out
}
}
fn static_ctx_name(name: &str) -> Option<&'static str> {
match name {
"global" => Some("global"),
"leader" => Some("leader"),
"leader_buffer" => Some("leader_buffer"),
"leader_window" => Some("leader_window"),
"leader_file" => Some("leader_file"),
"leader_quit" => Some("leader_quit"),
"leader_snippet" => Some("leader_snippet"),
"sidebar" => Some("sidebar"),
"scripts" => Some("scripts"),
"grid" => Some("grid"),
"oil" => Some("oil"),
"overlay" => Some("overlay"),
_ => None,
}
}
pub fn config_path() -> Option<PathBuf> {
let dirs = directories::ProjectDirs::from("", "", "dbtui")?;
Some(dirs.config_dir().join("keybindings.toml"))
}
pub fn write_default_config() -> std::io::Result<PathBuf> {
let path = config_path().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "no config dir available")
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = KeyBindings::defaults().to_toml();
std::fs::write(&path, content)?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn defaults_load_without_panicking() {
let kb = KeyBindings::defaults();
assert!(!kb.string_map.is_empty());
}
#[test]
fn defaults_match_well_known_keys() {
let kb = KeyBindings::defaults();
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
assert!(kb.matches(Context::Sidebar, "scroll_down", &j));
let r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
assert!(!kb.matches(Context::Sidebar, "scroll_down", &r));
}
#[test]
fn dump_round_trips() {
let kb = KeyBindings::defaults();
let toml_str = kb.to_toml();
let _: BTreeMap<String, BTreeMap<String, toml::Value>> = toml::from_str(&toml_str).unwrap();
}
}