use ratatui::style::Color;
use crate::agent::{AgentModel, Effort};
use crate::cx::Env;
use crate::keys::{KeyAction, KeyChord, Keymap};
use crate::model::Column;
use crate::output::color::{ColorChoice, resolve_color};
use crate::template::DEFAULT_TEMPLATE;
use crate::tui::theme::{Palette, ThemePreset};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SubmoduleInit {
#[default]
Prompt,
Never,
Always,
}
impl SubmoduleInit {
pub fn parse(value: &str) -> Option<SubmoduleInit> {
match value {
"prompt" => Some(SubmoduleInit::Prompt),
"never" => Some(SubmoduleInit::Never),
"always" => Some(SubmoduleInit::Always),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub path_template: String,
pub default_base: Option<String>,
pub copy: Vec<String>,
pub hooks_post_create: Option<String>,
pub hooks_pre_remove: Option<String>,
pub editor: Option<String>,
pub remove_delete_merged_branch: bool,
pub remove_untracked_blocks: bool,
pub pr_default_remote: String,
pub submodules_init: SubmoduleInit,
pub agent_model: AgentModel,
pub agent_effort: Effort,
pub list_show_untracked: bool,
pub list_columns: Vec<Column>,
pub ui_nerd_fonts: bool,
pub ui_mouse: bool,
pub ui_color: ColorChoice,
pub ui_theme: ThemePreset,
pub theme_overrides: ThemeOverrides,
pub keybinding_overrides: Vec<(KeyAction, KeyChord)>,
}
impl Default for Config {
fn default() -> Self {
Config {
path_template: DEFAULT_TEMPLATE.to_string(),
default_base: None,
copy: Vec::new(),
hooks_post_create: None,
hooks_pre_remove: None,
editor: None,
remove_delete_merged_branch: true,
remove_untracked_blocks: false,
pr_default_remote: "origin".to_string(),
submodules_init: SubmoduleInit::default(),
agent_model: AgentModel::default(),
agent_effort: Effort::default(),
list_show_untracked: true,
list_columns: Column::ALL.to_vec(),
ui_nerd_fonts: false,
ui_mouse: true,
ui_color: ColorChoice::Auto,
ui_theme: ThemePreset::default(),
theme_overrides: ThemeOverrides::default(),
keybinding_overrides: Vec::new(),
}
}
}
impl Config {
pub fn apply(&mut self, layer: ConfigLayer) {
if let Some(v) = layer.path_template {
self.path_template = v;
}
if let Some(v) = layer.default_base {
self.default_base = Some(v);
}
if let Some(v) = layer.copy {
self.copy = v;
}
if let Some(v) = layer.editor {
self.editor = Some(v);
}
if let Some(v) = layer.hooks_post_create {
self.hooks_post_create = Some(v);
}
if let Some(v) = layer.hooks_pre_remove {
self.hooks_pre_remove = Some(v);
}
if let Some(v) = layer.remove_delete_merged_branch {
self.remove_delete_merged_branch = v;
}
if let Some(v) = layer.remove_untracked_blocks {
self.remove_untracked_blocks = v;
}
if let Some(v) = layer.pr_default_remote {
self.pr_default_remote = v;
}
if let Some(v) = layer.submodules_init {
self.submodules_init = v;
}
if let Some(v) = layer.agent_model {
self.agent_model = v;
}
if let Some(v) = layer.agent_effort {
self.agent_effort = v;
}
if let Some(v) = layer.list_show_untracked {
self.list_show_untracked = v;
}
if let Some(v) = layer.list_columns {
self.list_columns = v;
}
if let Some(v) = layer.ui_nerd_fonts {
self.ui_nerd_fonts = v;
}
if let Some(v) = layer.ui_mouse {
self.ui_mouse = v;
}
if let Some(v) = layer.ui_color {
self.ui_color = v;
}
if let Some(v) = layer.ui_theme {
self.ui_theme = v;
}
self.theme_overrides.merge(layer.theme_overrides);
self.keybinding_overrides.extend(layer.ui_keybindings);
}
pub fn palette(&self) -> Palette {
let mut palette = self.ui_theme.palette();
self.theme_overrides.apply_to(&mut palette);
palette
}
pub fn keymap(&self) -> Keymap {
let mut keymap = Keymap::defaults();
for (action, chord) in &self.keybinding_overrides {
keymap.rebind(*action, *chord);
}
keymap
}
pub fn color_enabled(&self, flag: Option<ColorChoice>, env: &Env, stdout_is_tty: bool) -> bool {
resolve_color(
flag,
env.is_set_nonempty("NO_COLOR"),
Some(self.ui_color),
stdout_is_tty,
)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConfigLayer {
pub path_template: Option<String>,
pub default_base: Option<String>,
pub copy: Option<Vec<String>>,
pub editor: Option<String>,
pub hooks_post_create: Option<String>,
pub hooks_pre_remove: Option<String>,
pub remove_delete_merged_branch: Option<bool>,
pub remove_untracked_blocks: Option<bool>,
pub pr_default_remote: Option<String>,
pub submodules_init: Option<SubmoduleInit>,
pub agent_model: Option<AgentModel>,
pub agent_effort: Option<Effort>,
pub list_show_untracked: Option<bool>,
pub list_columns: Option<Vec<Column>>,
pub ui_nerd_fonts: Option<bool>,
pub ui_mouse: Option<bool>,
pub ui_color: Option<ColorChoice>,
pub ui_theme: Option<ThemePreset>,
pub theme_overrides: ThemeOverrides,
pub ui_keybindings: Vec<(KeyAction, KeyChord)>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ThemeOverrides {
pub accent: Option<Color>,
pub green: Option<Color>,
pub red: Option<Color>,
pub yellow: Option<Color>,
pub orange: Option<Color>,
pub cyan: Option<Color>,
pub magenta: Option<Color>,
pub gray: Option<Color>,
pub selection_bg: Option<Color>,
pub chip_fg: Option<Color>,
}
impl ThemeOverrides {
pub fn merge(&mut self, other: ThemeOverrides) {
self.accent = other.accent.or(self.accent);
self.green = other.green.or(self.green);
self.red = other.red.or(self.red);
self.yellow = other.yellow.or(self.yellow);
self.orange = other.orange.or(self.orange);
self.cyan = other.cyan.or(self.cyan);
self.magenta = other.magenta.or(self.magenta);
self.gray = other.gray.or(self.gray);
self.selection_bg = other.selection_bg.or(self.selection_bg);
self.chip_fg = other.chip_fg.or(self.chip_fg);
}
fn apply_to(&self, palette: &mut Palette) {
if let Some(c) = self.accent {
palette.accent = c;
}
if let Some(c) = self.green {
palette.green = c;
}
if let Some(c) = self.red {
palette.red = c;
}
if let Some(c) = self.yellow {
palette.yellow = c;
}
if let Some(c) = self.orange {
palette.orange = c;
}
if let Some(c) = self.cyan {
palette.cyan = c;
}
if let Some(c) = self.magenta {
palette.magenta = c;
}
if let Some(c) = self.gray {
palette.gray = c;
}
if let Some(c) = self.selection_bg {
palette.selection_bg = c;
}
if let Some(c) = self.chip_fg {
palette.chip_fg = c;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
#[test]
fn defaults_match_spec() {
let c = Config::default();
assert_eq!(c.path_template, DEFAULT_TEMPLATE);
assert!(c.default_base.is_none());
assert!(c.copy.is_empty());
assert!(c.remove_delete_merged_branch);
assert!(!c.remove_untracked_blocks);
assert_eq!(c.pr_default_remote, "origin");
assert_eq!(c.submodules_init, SubmoduleInit::Prompt);
assert_eq!(c.agent_model, AgentModel::Sonnet);
assert_eq!(c.agent_effort, Effort::Medium);
assert!(c.list_show_untracked);
assert_eq!(c.list_columns, Column::ALL.to_vec());
assert!(!c.ui_nerd_fonts);
assert!(c.ui_mouse);
assert_eq!(c.ui_color, ColorChoice::Auto);
}
#[test]
fn scalars_replace_on_apply() {
let mut c = Config::default();
c.apply(ConfigLayer {
pr_default_remote: Some("upstream".into()),
ui_mouse: Some(false),
..Default::default()
});
assert_eq!(c.pr_default_remote, "upstream");
assert!(!c.ui_mouse);
assert!(c.list_show_untracked);
}
#[test]
fn arrays_replace_wholesale() {
let mut c = Config::default();
c.apply(ConfigLayer {
copy: Some(vec![".env".into()]),
list_columns: Some(vec![Column::Branch, Column::Pr]),
..Default::default()
});
assert_eq!(c.copy, vec![".env".to_string()]);
assert_eq!(c.list_columns, vec![Column::Branch, Column::Pr]);
c.apply(ConfigLayer {
copy: Some(vec![".envrc".into()]),
..Default::default()
});
assert_eq!(c.copy, vec![".envrc".to_string()]);
}
#[test]
fn apply_sets_every_scalar_and_optional_field() {
let mut c = Config::default();
c.apply(ConfigLayer {
path_template: Some("{home}/{branch_slug}".into()),
default_base: Some("trunk".into()),
editor: Some("hx".into()),
hooks_post_create: Some("setup".into()),
hooks_pre_remove: Some("teardown".into()),
remove_delete_merged_branch: Some(false),
remove_untracked_blocks: Some(true),
submodules_init: Some(SubmoduleInit::Always),
agent_model: Some(AgentModel::Haiku),
agent_effort: Some(Effort::Low),
list_show_untracked: Some(false),
ui_nerd_fonts: Some(true),
ui_color: Some(ColorChoice::Never),
..Default::default()
});
assert_eq!(c.path_template, "{home}/{branch_slug}");
assert_eq!(c.default_base.as_deref(), Some("trunk"));
assert_eq!(c.editor.as_deref(), Some("hx"));
assert_eq!(c.hooks_post_create.as_deref(), Some("setup"));
assert_eq!(c.hooks_pre_remove.as_deref(), Some("teardown"));
assert!(!c.remove_delete_merged_branch);
assert!(c.remove_untracked_blocks);
assert_eq!(c.submodules_init, SubmoduleInit::Always);
assert_eq!(c.agent_model, AgentModel::Haiku);
assert_eq!(c.agent_effort, Effort::Low);
assert!(!c.list_show_untracked);
assert!(c.ui_nerd_fonts);
assert_eq!(c.ui_color, ColorChoice::Never);
}
#[test]
fn color_enabled_follows_precedence() {
use crate::output::color::ColorChoice;
let mut c = Config::default();
let no_env = Env::from_map(std::collections::HashMap::new());
assert!(c.color_enabled(None, &no_env, true));
assert!(!c.color_enabled(None, &no_env, false));
c.ui_color = ColorChoice::Never;
assert!(!c.color_enabled(None, &no_env, true));
assert!(c.color_enabled(Some(ColorChoice::Always), &no_env, false));
c.ui_color = ColorChoice::Always;
let no_color = Env::from_map(
[("NO_COLOR".to_string(), "1".to_string())]
.into_iter()
.collect(),
);
assert!(!c.color_enabled(None, &no_color, true));
}
#[test]
fn keybindings_deep_merge_per_action() {
let mut c = Config::default();
c.apply(ConfigLayer {
ui_keybindings: vec![(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w')))],
..Default::default()
});
c.apply(ConfigLayer {
ui_keybindings: vec![
(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('e'))),
(KeyAction::Quit, KeyChord::key(KeyCode::Char('x'))),
],
..Default::default()
});
let km = c.keymap();
assert_eq!(
km.action_for(KeyChord::key(KeyCode::Char('e'))),
Some(KeyAction::NavigateUp)
);
assert_eq!(km.action_for(KeyChord::key(KeyCode::Char('w'))), None);
assert_eq!(
km.action_for(KeyChord::key(KeyCode::Char('x'))),
Some(KeyAction::Quit)
);
assert_eq!(
km.action_for(KeyChord::key(KeyCode::Char('n'))),
Some(KeyAction::New)
);
}
#[test]
fn theme_defaults_to_one_dark() {
let c = Config::default();
assert_eq!(c.ui_theme, ThemePreset::OneDark);
assert_eq!(c.theme_overrides, ThemeOverrides::default());
assert_eq!(c.palette(), Palette::one_dark());
}
#[test]
fn theme_preset_and_overrides_apply_and_merge() {
let mut c = Config::default();
c.apply(ConfigLayer {
ui_theme: Some(ThemePreset::Solarized),
theme_overrides: ThemeOverrides {
accent: Some(Color::Rgb(1, 1, 1)),
..Default::default()
},
..Default::default()
});
c.apply(ConfigLayer {
theme_overrides: ThemeOverrides {
red: Some(Color::Rgb(2, 2, 2)),
..Default::default()
},
..Default::default()
});
assert_eq!(c.ui_theme, ThemePreset::Solarized);
let p = c.palette();
assert_eq!(p.accent, Color::Rgb(1, 1, 1));
assert_eq!(p.red, Color::Rgb(2, 2, 2));
assert_eq!(p.green, Palette::solarized().green);
}
#[test]
fn later_theme_override_wins_for_same_slot() {
let mut o = ThemeOverrides {
accent: Some(Color::Rgb(1, 1, 1)),
..Default::default()
};
o.merge(ThemeOverrides {
accent: Some(Color::Rgb(9, 9, 9)),
..Default::default()
});
assert_eq!(o.accent, Some(Color::Rgb(9, 9, 9)));
}
}