use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::style::Color;
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
Quit,
ToggleHelp,
CloseHelp,
ToggleFocus,
ToggleFilesPanel,
Reload,
NextFile,
PrevFile,
ScrollDown,
ScrollUp,
NextHunk,
PrevHunk,
HalfPageDown,
HalfPageUp,
Top,
Bottom,
Resnap,
FocusFiles,
FocusDiff,
EnterDiff,
OpenComment,
OpenSubmit,
SnapUp,
SnapDown,
OpenDrafts,
}
#[derive(Debug, Clone, Copy)]
pub struct KeyBinding {
pub code: KeyCode,
pub mods: KeyModifiers,
}
impl KeyBinding {
pub fn matches(&self, code: KeyCode, mods: KeyModifiers) -> bool {
let m = mods & (KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT);
self.code == code && self.mods == m
}
}
#[derive(Debug, Clone)]
pub struct Keymap {
pub bindings: Vec<(KeyBinding, Action)>,
}
impl Keymap {
pub fn lookup(&self, code: KeyCode, mods: KeyModifiers) -> Option<Action> {
self.bindings
.iter()
.find(|(b, _)| b.matches(code, mods))
.map(|(_, a)| *a)
}
fn default() -> Self {
let b = |s: &str, a: Action| (parse_keybinding(s).unwrap(), a);
Self {
bindings: vec![
b("q", Action::Quit),
b("?", Action::ToggleHelp),
b("Esc", Action::CloseHelp),
b("Tab", Action::ToggleFocus),
b("F", Action::ToggleFilesPanel),
b("r", Action::Reload),
b("n", Action::NextFile),
b("p", Action::PrevFile),
b("=", Action::Resnap),
b("j", Action::ScrollDown),
b("Down", Action::ScrollDown),
b("k", Action::ScrollUp),
b("Up", Action::ScrollUp),
b("J", Action::NextHunk),
b("K", Action::PrevHunk),
b("ctrl-d", Action::HalfPageDown),
b("ctrl-u", Action::HalfPageUp),
b("g", Action::Top),
b("G", Action::Bottom),
b("c", Action::OpenComment),
b("S", Action::OpenSubmit),
b("d", Action::OpenDrafts),
b("Enter", Action::EnterDiff),
b("[", Action::SnapUp),
b("]", Action::SnapDown),
b("h", Action::FocusFiles),
b("Left", Action::FocusFiles),
b("l", Action::FocusDiff),
b("Right", Action::FocusDiff),
],
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub bg_delete: Color,
pub bg_insert: Color,
pub bg_mod_left: Color,
pub bg_mod_right: Color,
pub hl_delete: Color,
pub hl_insert: Color,
pub fg_gutter: Color,
pub anchor_bright: Color,
pub anchor_soft: Color,
pub track_bg_left: Color,
pub track_bg_right: Color,
pub border_left_focus: Color,
pub border_left_dim: Color,
pub border_right_focus: Color,
pub border_right_dim: Color,
pub change_anchor_bright_left: Color,
pub change_anchor_dim_left: Color,
pub change_anchor_bright_right: Color,
pub change_anchor_dim_right: Color,
pub modify_anchor_bright: Color,
pub modify_anchor_dim: Color,
pub line_bright: Color,
pub line_dim: Color,
pub change_line_bright: Color,
pub change_line_dim: Color,
pub alignment_overlay: Color,
pub title_bg: Color,
pub title_fg: Color,
pub title_old: Color,
pub title_new: Color,
pub fg_standalone_left: Color,
pub fg_standalone_right: Color,
pub diff_focus: Color,
pub diff_dim: Color,
pub list_focus_bg: Color,
pub list_dim_bg: Color,
pub help_section_fg: Color,
pub help_keys_fg: Color,
pub help_desc_fg: Color,
pub help_border_fg: Color,
pub help_panel_bg: Color,
pub status_conflict_fg: Color,
}
impl Theme {
fn default() -> Self {
Self {
bg_delete: Color::Rgb(48, 22, 26),
bg_insert: Color::Rgb(20, 44, 26),
bg_mod_left: Color::Rgb(48, 22, 26),
bg_mod_right: Color::Rgb(20, 44, 26),
hl_delete: Color::Rgb(150, 60, 70),
hl_insert: Color::Rgb(50, 130, 70),
fg_gutter: Color::Rgb(120, 120, 120),
anchor_bright: Color::Rgb(245, 245, 250),
anchor_soft: Color::Rgb(190, 195, 210),
track_bg_left: Color::Rgb(60, 28, 32),
track_bg_right: Color::Rgb(26, 50, 32),
border_left_focus: Color::Rgb(220, 100, 110),
border_left_dim: Color::Rgb(140, 70, 80),
border_right_focus: Color::Rgb(110, 200, 130),
border_right_dim: Color::Rgb(70, 130, 90),
change_anchor_bright_left: Color::Rgb(230, 110, 120),
change_anchor_dim_left: Color::Rgb(170, 80, 90),
change_anchor_bright_right: Color::Rgb(120, 210, 140),
change_anchor_dim_right: Color::Rgb(80, 160, 100),
modify_anchor_bright: Color::Rgb(230, 200, 110),
modify_anchor_dim: Color::Rgb(170, 145, 70),
line_bright: Color::Rgb(245, 245, 250),
line_dim: Color::Rgb(190, 195, 210),
change_line_bright: Color::Rgb(190, 170, 120),
change_line_dim: Color::Rgb(140, 130, 90),
alignment_overlay: Color::Rgb(40, 50, 70),
title_bg: Color::Rgb(60, 90, 140),
title_fg: Color::White,
title_old: Color::Rgb(200, 120, 120),
title_new: Color::Rgb(120, 200, 140),
fg_standalone_left: Color::Rgb(230, 190, 195),
fg_standalone_right: Color::Rgb(190, 230, 195),
diff_focus: Color::Rgb(120, 160, 220),
diff_dim: Color::DarkGray,
list_focus_bg: Color::Rgb(50, 70, 100),
list_dim_bg: Color::Rgb(50, 50, 50),
help_section_fg: Color::Rgb(225, 200, 130),
help_keys_fg: Color::Rgb(170, 210, 245),
help_desc_fg: Color::Rgb(210, 215, 220),
help_border_fg: Color::Rgb(120, 160, 220),
help_panel_bg: Color::Rgb(20, 24, 32),
status_conflict_fg: Color::Rgb(245, 130, 80),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct LayoutConfig {
pub files_panel_width_pct: u16,
pub ribbon_width: u16,
pub target_y_divisor: usize,
}
impl LayoutConfig {
fn default() -> Self {
Self {
files_panel_width_pct: 20,
ribbon_width: 5,
target_y_divisor: 3,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct BehaviorConfig {
pub tick_ms: u64,
pub scroll_step: usize,
pub half_page_step: usize,
pub similarity_threshold: f32,
}
impl BehaviorConfig {
fn default() -> Self {
Self {
tick_ms: 250,
scroll_step: 3,
half_page_step: 15,
similarity_threshold: 0.30,
}
}
}
#[derive(Debug, Clone)]
pub struct I18nConfig {
pub lang: String,
}
impl I18nConfig {
fn default() -> Self {
let lang = std::env::var("LZIFF_LANG")
.or_else(|_| std::env::var("LANG"))
.unwrap_or_default();
let lang = if lang.starts_with("ja") { "ja" } else { "en" };
Self {
lang: lang.to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub theme: Theme,
pub keymap: Keymap,
pub layout: LayoutConfig,
pub behavior: BehaviorConfig,
pub i18n: I18nConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
theme: Theme::default(),
keymap: Keymap::default(),
layout: LayoutConfig::default(),
behavior: BehaviorConfig::default(),
i18n: I18nConfig::default(),
}
}
}
impl Config {
pub fn load() -> Self {
Self::load_inner().unwrap_or_default()
}
fn load_inner() -> Result<Self> {
let path = config_path()?;
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
let raw: RawConfig = toml::from_str(&text)
.with_context(|| format!("parse {}", path.display()))?;
Ok(raw.apply(Self::default()))
}
}
pub fn config_path() -> Result<PathBuf> {
let dir = dirs::config_dir().ok_or_else(|| anyhow::anyhow!("config dir unavailable"))?;
Ok(dir.join("lziff").join("config.toml"))
}
#[derive(Debug, Default, Deserialize)]
struct RawConfig {
#[serde(default)]
theme: RawTheme,
#[serde(default)]
keymap: Option<toml::Table>,
#[serde(default)]
layout: RawLayout,
#[serde(default)]
behavior: RawBehavior,
#[serde(default)]
i18n: RawI18n,
}
impl RawConfig {
fn apply(self, mut cfg: Config) -> Config {
self.theme.apply(&mut cfg.theme);
if let Some(map) = self.keymap {
apply_keymap(map, &mut cfg.keymap);
}
self.layout.apply(&mut cfg.layout);
self.behavior.apply(&mut cfg.behavior);
self.i18n.apply(&mut cfg.i18n);
cfg
}
}
macro_rules! raw_color_struct {
($name:ident { $($field:ident),* $(,)? }) => {
#[derive(Debug, Default, Deserialize)]
struct $name {
$( #[serde(default)] $field: Option<HexColor>, )*
}
impl $name {
fn apply(self, t: &mut Theme) {
$( if let Some(c) = self.$field { t.$field = c.0; } )*
}
}
};
}
raw_color_struct!(RawTheme {
bg_delete,
bg_insert,
bg_mod_left,
bg_mod_right,
hl_delete,
hl_insert,
fg_gutter,
anchor_bright,
anchor_soft,
track_bg_left,
track_bg_right,
border_left_focus,
border_left_dim,
border_right_focus,
border_right_dim,
change_anchor_bright_left,
change_anchor_dim_left,
change_anchor_bright_right,
change_anchor_dim_right,
modify_anchor_bright,
modify_anchor_dim,
line_bright,
line_dim,
change_line_bright,
change_line_dim,
alignment_overlay,
title_bg,
title_fg,
title_old,
title_new,
fg_standalone_left,
fg_standalone_right,
diff_focus,
diff_dim,
list_focus_bg,
list_dim_bg,
help_section_fg,
help_keys_fg,
help_desc_fg,
help_border_fg,
help_panel_bg,
status_conflict_fg,
});
#[derive(Debug, Default, Deserialize)]
struct RawLayout {
#[serde(default)]
files_panel_width_pct: Option<u16>,
#[serde(default)]
ribbon_width: Option<u16>,
#[serde(default)]
target_y_divisor: Option<usize>,
}
impl RawLayout {
fn apply(self, l: &mut LayoutConfig) {
if let Some(v) = self.files_panel_width_pct {
l.files_panel_width_pct = v;
}
if let Some(v) = self.ribbon_width {
l.ribbon_width = v;
}
if let Some(v) = self.target_y_divisor {
l.target_y_divisor = v;
}
}
}
#[derive(Debug, Default, Deserialize)]
struct RawBehavior {
#[serde(default)]
tick_ms: Option<u64>,
#[serde(default)]
scroll_step: Option<usize>,
#[serde(default)]
half_page_step: Option<usize>,
#[serde(default)]
similarity_threshold: Option<f32>,
}
impl RawBehavior {
fn apply(self, b: &mut BehaviorConfig) {
if let Some(v) = self.tick_ms {
b.tick_ms = v;
}
if let Some(v) = self.scroll_step {
b.scroll_step = v;
}
if let Some(v) = self.half_page_step {
b.half_page_step = v;
}
if let Some(v) = self.similarity_threshold {
b.similarity_threshold = v;
}
}
}
#[derive(Debug, Default, Deserialize)]
struct RawI18n {
#[serde(default)]
lang: Option<String>,
}
impl RawI18n {
fn apply(self, i: &mut I18nConfig) {
if let Some(v) = self.lang {
i.lang = v;
}
}
}
fn apply_keymap(map: toml::Table, keymap: &mut Keymap) {
let mut overrides: std::collections::HashMap<Action, Vec<KeyBinding>> =
std::collections::HashMap::new();
for (key, value) in map {
let Some(action) = parse_action(&key) else {
continue;
};
let 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 parsed: Vec<KeyBinding> = strings.iter().filter_map(|s| parse_keybinding(s)).collect();
if !parsed.is_empty() {
overrides.insert(action, parsed);
}
}
let overridden_actions: std::collections::HashSet<Action> = overrides.keys().copied().collect();
keymap
.bindings
.retain(|(_, a)| !overridden_actions.contains(a));
let new_keys: std::collections::HashSet<(KeyCode, KeyModifiers)> = overrides
.values()
.flat_map(|v| v.iter().map(|kb| (kb.code, kb.mods)))
.collect();
keymap
.bindings
.retain(|(b, _)| !new_keys.contains(&(b.code, b.mods)));
for (action, bindings) in overrides {
for b in bindings {
keymap.bindings.push((b, action));
}
}
}
fn parse_action(s: &str) -> Option<Action> {
Some(match s {
"quit" => Action::Quit,
"toggle_help" => Action::ToggleHelp,
"close_help" => Action::CloseHelp,
"toggle_focus" => Action::ToggleFocus,
"toggle_files_panel" => Action::ToggleFilesPanel,
"reload" => Action::Reload,
"next_file" => Action::NextFile,
"prev_file" => Action::PrevFile,
"scroll_down" => Action::ScrollDown,
"scroll_up" => Action::ScrollUp,
"next_hunk" => Action::NextHunk,
"prev_hunk" => Action::PrevHunk,
"half_page_down" => Action::HalfPageDown,
"half_page_up" => Action::HalfPageUp,
"top" => Action::Top,
"bottom" => Action::Bottom,
"resnap" => Action::Resnap,
"focus_files" => Action::FocusFiles,
"focus_diff" => Action::FocusDiff,
"enter_diff" => Action::EnterDiff,
"open_comment" => Action::OpenComment,
"open_submit" => Action::OpenSubmit,
"snap_up" => Action::SnapUp,
"snap_down" => Action::SnapDown,
"open_drafts" => Action::OpenDrafts,
_ => return None,
})
}
fn parse_keybinding(s: &str) -> Option<KeyBinding> {
let mut mods = KeyModifiers::empty();
let mut last = s;
while let Some((prefix, rest)) = last.split_once('-') {
match prefix.to_ascii_lowercase().as_str() {
"ctrl" | "c" => mods |= KeyModifiers::CONTROL,
"shift" | "s" => mods |= KeyModifiers::SHIFT,
"alt" | "meta" | "a" | "m" => mods |= KeyModifiers::ALT,
_ => break,
}
last = rest;
}
let code = parse_keycode(last)?;
if let KeyCode::Char(c) = code {
if c.is_ascii_uppercase() {
mods |= KeyModifiers::SHIFT;
}
}
Some(KeyBinding { code, mods })
}
fn parse_keycode(s: &str) -> Option<KeyCode> {
Some(match s {
"Enter" | "Return" => KeyCode::Enter,
"Esc" | "Escape" => KeyCode::Esc,
"Tab" => KeyCode::Tab,
"BackTab" => KeyCode::BackTab,
"Up" => KeyCode::Up,
"Down" => KeyCode::Down,
"Left" => KeyCode::Left,
"Right" => KeyCode::Right,
"Home" => KeyCode::Home,
"End" => KeyCode::End,
"PageUp" | "PgUp" => KeyCode::PageUp,
"PageDown" | "PgDn" => KeyCode::PageDown,
"Backspace" => KeyCode::Backspace,
"Delete" | "Del" => KeyCode::Delete,
"Insert" | "Ins" => KeyCode::Insert,
"Space" => KeyCode::Char(' '),
s if s.starts_with('F') && s.len() > 1 => {
let n: u8 = s[1..].parse().ok()?;
KeyCode::F(n)
}
s => {
let mut chars = s.chars();
let c = chars.next()?;
if chars.next().is_some() {
return None;
}
KeyCode::Char(c)
}
})
}
#[derive(Debug, Clone, Copy)]
struct HexColor(Color);
impl<'de> Deserialize<'de> for HexColor {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
parse_hex_color(&s)
.map(HexColor)
.ok_or_else(|| serde::de::Error::custom(format!("invalid color: {s}")))
}
}
fn parse_hex_color(s: &str) -> Option<Color> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_load_cleanly() {
let cfg = Config::default();
assert!(cfg.keymap.bindings.iter().any(|(_, a)| *a == Action::Quit));
}
#[test]
fn parses_named_keys() {
let kb = parse_keybinding("ctrl-d").unwrap();
assert!(kb.mods.contains(KeyModifiers::CONTROL));
assert_eq!(kb.code, KeyCode::Char('d'));
let kb = parse_keybinding("Up").unwrap();
assert_eq!(kb.code, KeyCode::Up);
}
#[test]
fn shifted_uppercase_implies_shift() {
let kb = parse_keybinding("J").unwrap();
assert!(kb.mods.contains(KeyModifiers::SHIFT));
}
#[test]
fn hex_color_parses() {
assert_eq!(parse_hex_color("#102030"), Some(Color::Rgb(16, 32, 48)));
assert_eq!(parse_hex_color("ffffff"), Some(Color::Rgb(255, 255, 255)));
assert!(parse_hex_color("#abc").is_none());
}
#[test]
fn toml_overlay_applies() {
let toml = r##"
[theme]
bg_delete = "#112233"
[behavior]
scroll_step = 7
similarity_threshold = 0.5
[keymap]
quit = ["q", "Esc"]
"##;
let raw: RawConfig = toml::from_str(toml).unwrap();
let cfg = raw.apply(Config::default());
assert_eq!(cfg.theme.bg_delete, Color::Rgb(0x11, 0x22, 0x33));
assert_eq!(cfg.behavior.scroll_step, 7);
assert!((cfg.behavior.similarity_threshold - 0.5).abs() < 1e-6);
assert!(cfg
.keymap
.lookup(KeyCode::Esc, KeyModifiers::empty())
.map(|a| a == Action::Quit)
.unwrap_or(false));
}
}