1use ratatui::style::Color;
5
6use crate::agent::{AgentModel, Effort};
7use crate::cx::Env;
8use crate::keys::{KeyAction, KeyChord, Keymap};
9use crate::model::Column;
10use crate::output::color::{ColorChoice, resolve_color};
11use crate::template::DEFAULT_TEMPLATE;
12use crate::tui::theme::{Palette, ThemePreset};
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub enum SubmoduleInit {
22 #[default]
26 Prompt,
27 Never,
29 Always,
32}
33
34impl SubmoduleInit {
35 pub fn parse(value: &str) -> Option<SubmoduleInit> {
37 match value {
38 "prompt" => Some(SubmoduleInit::Prompt),
39 "never" => Some(SubmoduleInit::Never),
40 "always" => Some(SubmoduleInit::Always),
41 _ => None,
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Config {
49 pub path_template: String,
51 pub default_base: Option<String>,
54 pub copy: Vec<String>,
56 pub hooks_post_create: Option<String>,
58 pub hooks_pre_remove: Option<String>,
60 pub editor: Option<String>,
62 pub remove_delete_merged_branch: bool,
64 pub remove_untracked_blocks: bool,
66 pub pr_default_remote: String,
68 pub submodules_init: SubmoduleInit,
70 pub agent_model: AgentModel,
73 pub agent_effort: Effort,
76 pub list_show_untracked: bool,
78 pub list_columns: Vec<Column>,
80 pub ui_nerd_fonts: bool,
82 pub ui_mouse: bool,
84 pub ui_color: ColorChoice,
86 pub ui_theme: ThemePreset,
88 pub theme_overrides: ThemeOverrides,
90 pub keybinding_overrides: Vec<(KeyAction, KeyChord)>,
92}
93
94impl Default for Config {
95 fn default() -> Self {
96 Config {
97 path_template: DEFAULT_TEMPLATE.to_string(),
98 default_base: None,
99 copy: Vec::new(),
100 hooks_post_create: None,
101 hooks_pre_remove: None,
102 editor: None,
103 remove_delete_merged_branch: true,
104 remove_untracked_blocks: false,
105 pr_default_remote: "origin".to_string(),
106 submodules_init: SubmoduleInit::default(),
107 agent_model: AgentModel::default(),
108 agent_effort: Effort::default(),
109 list_show_untracked: true,
110 list_columns: Column::ALL.to_vec(),
111 ui_nerd_fonts: false,
112 ui_mouse: true,
113 ui_color: ColorChoice::Auto,
114 ui_theme: ThemePreset::default(),
115 theme_overrides: ThemeOverrides::default(),
116 keybinding_overrides: Vec::new(),
117 }
118 }
119}
120
121impl Config {
122 pub fn apply(&mut self, layer: ConfigLayer) {
128 if let Some(v) = layer.path_template {
129 self.path_template = v;
130 }
131 if let Some(v) = layer.default_base {
132 self.default_base = Some(v);
133 }
134 if let Some(v) = layer.copy {
135 self.copy = v;
136 }
137 if let Some(v) = layer.editor {
138 self.editor = Some(v);
139 }
140 if let Some(v) = layer.hooks_post_create {
141 self.hooks_post_create = Some(v);
142 }
143 if let Some(v) = layer.hooks_pre_remove {
144 self.hooks_pre_remove = Some(v);
145 }
146 if let Some(v) = layer.remove_delete_merged_branch {
147 self.remove_delete_merged_branch = v;
148 }
149 if let Some(v) = layer.remove_untracked_blocks {
150 self.remove_untracked_blocks = v;
151 }
152 if let Some(v) = layer.pr_default_remote {
153 self.pr_default_remote = v;
154 }
155 if let Some(v) = layer.submodules_init {
156 self.submodules_init = v;
157 }
158 if let Some(v) = layer.agent_model {
159 self.agent_model = v;
160 }
161 if let Some(v) = layer.agent_effort {
162 self.agent_effort = v;
163 }
164 if let Some(v) = layer.list_show_untracked {
165 self.list_show_untracked = v;
166 }
167 if let Some(v) = layer.list_columns {
168 self.list_columns = v;
169 }
170 if let Some(v) = layer.ui_nerd_fonts {
171 self.ui_nerd_fonts = v;
172 }
173 if let Some(v) = layer.ui_mouse {
174 self.ui_mouse = v;
175 }
176 if let Some(v) = layer.ui_color {
177 self.ui_color = v;
178 }
179 if let Some(v) = layer.ui_theme {
180 self.ui_theme = v;
181 }
182 self.theme_overrides.merge(layer.theme_overrides);
183 self.keybinding_overrides.extend(layer.ui_keybindings);
184 }
185
186 pub fn palette(&self) -> Palette {
189 let mut palette = self.ui_theme.palette();
190 self.theme_overrides.apply_to(&mut palette);
191 palette
192 }
193
194 pub fn keymap(&self) -> Keymap {
197 let mut keymap = Keymap::defaults();
198 for (action, chord) in &self.keybinding_overrides {
199 keymap.rebind(*action, *chord);
200 }
201 keymap
202 }
203
204 pub fn color_enabled(&self, flag: Option<ColorChoice>, env: &Env, stdout_is_tty: bool) -> bool {
207 resolve_color(
208 flag,
209 env.is_set_nonempty("NO_COLOR"),
210 Some(self.ui_color),
211 stdout_is_tty,
212 )
213 }
214}
215
216#[derive(Debug, Clone, Default, PartialEq, Eq)]
219pub struct ConfigLayer {
220 pub path_template: Option<String>,
222 pub default_base: Option<String>,
224 pub copy: Option<Vec<String>>,
226 pub editor: Option<String>,
228 pub hooks_post_create: Option<String>,
230 pub hooks_pre_remove: Option<String>,
232 pub remove_delete_merged_branch: Option<bool>,
234 pub remove_untracked_blocks: Option<bool>,
236 pub pr_default_remote: Option<String>,
238 pub submodules_init: Option<SubmoduleInit>,
240 pub agent_model: Option<AgentModel>,
242 pub agent_effort: Option<Effort>,
244 pub list_show_untracked: Option<bool>,
246 pub list_columns: Option<Vec<Column>>,
248 pub ui_nerd_fonts: Option<bool>,
250 pub ui_mouse: Option<bool>,
252 pub ui_color: Option<ColorChoice>,
254 pub ui_theme: Option<ThemePreset>,
256 pub theme_overrides: ThemeOverrides,
258 pub ui_keybindings: Vec<(KeyAction, KeyChord)>,
260}
261
262#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
265pub struct ThemeOverrides {
266 pub accent: Option<Color>,
268 pub green: Option<Color>,
270 pub red: Option<Color>,
272 pub yellow: Option<Color>,
274 pub orange: Option<Color>,
276 pub cyan: Option<Color>,
278 pub magenta: Option<Color>,
280 pub gray: Option<Color>,
282 pub selection_bg: Option<Color>,
284 pub chip_fg: Option<Color>,
286}
287
288impl ThemeOverrides {
289 pub fn merge(&mut self, other: ThemeOverrides) {
291 self.accent = other.accent.or(self.accent);
292 self.green = other.green.or(self.green);
293 self.red = other.red.or(self.red);
294 self.yellow = other.yellow.or(self.yellow);
295 self.orange = other.orange.or(self.orange);
296 self.cyan = other.cyan.or(self.cyan);
297 self.magenta = other.magenta.or(self.magenta);
298 self.gray = other.gray.or(self.gray);
299 self.selection_bg = other.selection_bg.or(self.selection_bg);
300 self.chip_fg = other.chip_fg.or(self.chip_fg);
301 }
302
303 fn apply_to(&self, palette: &mut Palette) {
305 if let Some(c) = self.accent {
306 palette.accent = c;
307 }
308 if let Some(c) = self.green {
309 palette.green = c;
310 }
311 if let Some(c) = self.red {
312 palette.red = c;
313 }
314 if let Some(c) = self.yellow {
315 palette.yellow = c;
316 }
317 if let Some(c) = self.orange {
318 palette.orange = c;
319 }
320 if let Some(c) = self.cyan {
321 palette.cyan = c;
322 }
323 if let Some(c) = self.magenta {
324 palette.magenta = c;
325 }
326 if let Some(c) = self.gray {
327 palette.gray = c;
328 }
329 if let Some(c) = self.selection_bg {
330 palette.selection_bg = c;
331 }
332 if let Some(c) = self.chip_fg {
333 palette.chip_fg = c;
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crossterm::event::KeyCode;
342
343 #[test]
344 fn defaults_match_spec() {
345 let c = Config::default();
346 assert_eq!(c.path_template, DEFAULT_TEMPLATE);
347 assert!(c.default_base.is_none());
348 assert!(c.copy.is_empty());
349 assert!(c.remove_delete_merged_branch);
350 assert!(!c.remove_untracked_blocks);
351 assert_eq!(c.pr_default_remote, "origin");
352 assert_eq!(c.submodules_init, SubmoduleInit::Prompt);
353 assert_eq!(c.agent_model, AgentModel::Sonnet);
354 assert_eq!(c.agent_effort, Effort::Medium);
355 assert!(c.list_show_untracked);
356 assert_eq!(c.list_columns, Column::ALL.to_vec());
357 assert!(!c.ui_nerd_fonts);
358 assert!(c.ui_mouse);
359 assert_eq!(c.ui_color, ColorChoice::Auto);
360 }
361
362 #[test]
363 fn scalars_replace_on_apply() {
364 let mut c = Config::default();
365 c.apply(ConfigLayer {
366 pr_default_remote: Some("upstream".into()),
367 ui_mouse: Some(false),
368 ..Default::default()
369 });
370 assert_eq!(c.pr_default_remote, "upstream");
371 assert!(!c.ui_mouse);
372 assert!(c.list_show_untracked);
374 }
375
376 #[test]
377 fn arrays_replace_wholesale() {
378 let mut c = Config::default();
379 c.apply(ConfigLayer {
380 copy: Some(vec![".env".into()]),
381 list_columns: Some(vec![Column::Branch, Column::Pr]),
382 ..Default::default()
383 });
384 assert_eq!(c.copy, vec![".env".to_string()]);
385 assert_eq!(c.list_columns, vec![Column::Branch, Column::Pr]);
386 c.apply(ConfigLayer {
388 copy: Some(vec![".envrc".into()]),
389 ..Default::default()
390 });
391 assert_eq!(c.copy, vec![".envrc".to_string()]);
392 }
393
394 #[test]
395 fn apply_sets_every_scalar_and_optional_field() {
396 let mut c = Config::default();
397 c.apply(ConfigLayer {
398 path_template: Some("{home}/{branch_slug}".into()),
399 default_base: Some("trunk".into()),
400 editor: Some("hx".into()),
401 hooks_post_create: Some("setup".into()),
402 hooks_pre_remove: Some("teardown".into()),
403 remove_delete_merged_branch: Some(false),
404 remove_untracked_blocks: Some(true),
405 submodules_init: Some(SubmoduleInit::Always),
406 agent_model: Some(AgentModel::Haiku),
407 agent_effort: Some(Effort::Low),
408 list_show_untracked: Some(false),
409 ui_nerd_fonts: Some(true),
410 ui_color: Some(ColorChoice::Never),
411 ..Default::default()
412 });
413 assert_eq!(c.path_template, "{home}/{branch_slug}");
414 assert_eq!(c.default_base.as_deref(), Some("trunk"));
415 assert_eq!(c.editor.as_deref(), Some("hx"));
416 assert_eq!(c.hooks_post_create.as_deref(), Some("setup"));
417 assert_eq!(c.hooks_pre_remove.as_deref(), Some("teardown"));
418 assert!(!c.remove_delete_merged_branch);
419 assert!(c.remove_untracked_blocks);
420 assert_eq!(c.submodules_init, SubmoduleInit::Always);
421 assert_eq!(c.agent_model, AgentModel::Haiku);
422 assert_eq!(c.agent_effort, Effort::Low);
423 assert!(!c.list_show_untracked);
424 assert!(c.ui_nerd_fonts);
425 assert_eq!(c.ui_color, ColorChoice::Never);
426 }
427
428 #[test]
429 fn color_enabled_follows_precedence() {
430 use crate::output::color::ColorChoice;
431 let mut c = Config::default();
432 let no_env = Env::from_map(std::collections::HashMap::new());
433 assert!(c.color_enabled(None, &no_env, true));
435 assert!(!c.color_enabled(None, &no_env, false));
436 c.ui_color = ColorChoice::Never;
438 assert!(!c.color_enabled(None, &no_env, true));
439 assert!(c.color_enabled(Some(ColorChoice::Always), &no_env, false));
441 c.ui_color = ColorChoice::Always;
443 let no_color = Env::from_map(
444 [("NO_COLOR".to_string(), "1".to_string())]
445 .into_iter()
446 .collect(),
447 );
448 assert!(!c.color_enabled(None, &no_color, true));
449 }
450
451 #[test]
452 fn keybindings_deep_merge_per_action() {
453 let mut c = Config::default();
454 c.apply(ConfigLayer {
456 ui_keybindings: vec![(KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('w')))],
457 ..Default::default()
458 });
459 c.apply(ConfigLayer {
461 ui_keybindings: vec![
462 (KeyAction::NavigateUp, KeyChord::key(KeyCode::Char('e'))),
463 (KeyAction::Quit, KeyChord::key(KeyCode::Char('x'))),
464 ],
465 ..Default::default()
466 });
467 let km = c.keymap();
468 assert_eq!(
470 km.action_for(KeyChord::key(KeyCode::Char('e'))),
471 Some(KeyAction::NavigateUp)
472 );
473 assert_eq!(km.action_for(KeyChord::key(KeyCode::Char('w'))), None);
474 assert_eq!(
476 km.action_for(KeyChord::key(KeyCode::Char('x'))),
477 Some(KeyAction::Quit)
478 );
479 assert_eq!(
480 km.action_for(KeyChord::key(KeyCode::Char('n'))),
481 Some(KeyAction::New)
482 );
483 }
484
485 #[test]
486 fn theme_defaults_to_one_dark() {
487 let c = Config::default();
488 assert_eq!(c.ui_theme, ThemePreset::OneDark);
489 assert_eq!(c.theme_overrides, ThemeOverrides::default());
490 assert_eq!(c.palette(), Palette::one_dark());
491 }
492
493 #[test]
494 fn theme_preset_and_overrides_apply_and_merge() {
495 let mut c = Config::default();
496 c.apply(ConfigLayer {
498 ui_theme: Some(ThemePreset::Solarized),
499 theme_overrides: ThemeOverrides {
500 accent: Some(Color::Rgb(1, 1, 1)),
501 ..Default::default()
502 },
503 ..Default::default()
504 });
505 c.apply(ConfigLayer {
507 theme_overrides: ThemeOverrides {
508 red: Some(Color::Rgb(2, 2, 2)),
509 ..Default::default()
510 },
511 ..Default::default()
512 });
513 assert_eq!(c.ui_theme, ThemePreset::Solarized);
514 let p = c.palette();
515 assert_eq!(p.accent, Color::Rgb(1, 1, 1));
517 assert_eq!(p.red, Color::Rgb(2, 2, 2));
518 assert_eq!(p.green, Palette::solarized().green);
520 }
521
522 #[test]
523 fn later_theme_override_wins_for_same_slot() {
524 let mut o = ThemeOverrides {
525 accent: Some(Color::Rgb(1, 1, 1)),
526 ..Default::default()
527 };
528 o.merge(ThemeOverrides {
529 accent: Some(Color::Rgb(9, 9, 9)),
530 ..Default::default()
531 });
532 assert_eq!(o.accent, Some(Color::Rgb(9, 9, 9)));
533 }
534}