1use ratatui::style::{Color, Modifier, Style};
14
15use crate::model::PrState;
16use crate::tui::app::{Mode, StatusKind};
17
18const ACCENT: Color = Color::Rgb(97, 175, 239);
20const GREEN: Color = Color::Rgb(152, 195, 121);
22const RED: Color = Color::Rgb(224, 108, 117);
24const YELLOW: Color = Color::Rgb(229, 192, 123);
26const ORANGE: Color = Color::Rgb(209, 154, 102);
28const CYAN: Color = Color::Rgb(86, 182, 194);
30const MAGENTA: Color = Color::Rgb(198, 120, 221);
32const GRAY: Color = Color::Rgb(92, 99, 112);
34const SELECTION_BG: Color = Color::Rgb(62, 68, 81);
36const CHIP_FG: Color = Color::Rgb(30, 33, 39);
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Palette {
43 pub accent: Color,
45 pub green: Color,
47 pub red: Color,
49 pub yellow: Color,
51 pub orange: Color,
53 pub cyan: Color,
55 pub magenta: Color,
57 pub gray: Color,
59 pub selection_bg: Color,
61 pub chip_fg: Color,
63}
64
65impl Palette {
66 pub fn one_dark() -> Palette {
68 Palette {
69 accent: ACCENT,
70 green: GREEN,
71 red: RED,
72 yellow: YELLOW,
73 orange: ORANGE,
74 cyan: CYAN,
75 magenta: MAGENTA,
76 gray: GRAY,
77 selection_bg: SELECTION_BG,
78 chip_fg: CHIP_FG,
79 }
80 }
81
82 pub fn solarized() -> Palette {
84 Palette {
85 accent: Color::Rgb(38, 139, 210), green: Color::Rgb(133, 153, 0), red: Color::Rgb(220, 50, 47), yellow: Color::Rgb(181, 137, 0), orange: Color::Rgb(203, 75, 22), cyan: Color::Rgb(42, 161, 152), magenta: Color::Rgb(211, 54, 130), gray: Color::Rgb(88, 110, 117), selection_bg: Color::Rgb(7, 54, 66), chip_fg: Color::Rgb(0, 43, 54), }
96 }
97}
98
99impl Default for Palette {
100 fn default() -> Self {
101 Palette::one_dark()
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
107pub enum ThemePreset {
108 #[default]
110 OneDark,
111 Solarized,
113}
114
115impl ThemePreset {
116 pub fn parse(s: &str) -> Option<ThemePreset> {
118 match s {
119 "one-dark" => Some(ThemePreset::OneDark),
120 "solarized" => Some(ThemePreset::Solarized),
121 _ => None,
122 }
123 }
124
125 pub fn id(self) -> &'static str {
127 match self {
128 ThemePreset::OneDark => "one-dark",
129 ThemePreset::Solarized => "solarized",
130 }
131 }
132
133 pub fn palette(self) -> Palette {
135 match self {
136 ThemePreset::OneDark => Palette::one_dark(),
137 ThemePreset::Solarized => Palette::solarized(),
138 }
139 }
140}
141
142pub struct Theme {
146 enabled: bool,
147 palette: Palette,
148}
149
150impl Theme {
151 pub fn new(enabled: bool) -> Theme {
155 Theme {
156 enabled,
157 palette: Palette::one_dark(),
158 }
159 }
160
161 pub fn with_palette(enabled: bool, palette: Palette) -> Theme {
164 Theme { enabled, palette }
165 }
166
167 pub fn enabled(&self) -> bool {
169 self.enabled
170 }
171
172 fn fg(&self, color: Color) -> Style {
174 if self.enabled {
175 Style::default().fg(color)
176 } else {
177 Style::default()
178 }
179 }
180
181 fn styled(&self, color: Color, modifier: Modifier) -> Style {
183 if self.enabled {
184 Style::default().fg(color).add_modifier(modifier)
185 } else {
186 Style::default()
187 }
188 }
189
190 pub fn current(&self) -> Style {
192 self.styled(self.palette.green, Modifier::BOLD)
193 }
194
195 pub fn missing(&self) -> Style {
197 self.fg(self.palette.red)
198 }
199
200 pub fn detached(&self) -> Style {
202 self.fg(self.palette.yellow)
203 }
204
205 pub fn branchless(&self) -> Style {
208 self.fg(self.palette.gray)
209 }
210
211 pub fn dirty(&self) -> Style {
213 self.fg(self.palette.yellow)
214 }
215
216 pub fn untracked(&self) -> Style {
218 self.fg(self.palette.cyan)
219 }
220
221 pub fn absent(&self) -> Style {
223 self.fg(self.palette.gray)
224 }
225
226 pub fn spinner(&self) -> Style {
228 self.fg(self.palette.gray)
229 }
230
231 pub fn ahead(&self, count: u32) -> Style {
233 if count > 0 {
234 self.fg(self.palette.green)
235 } else {
236 self.fg(self.palette.gray)
237 }
238 }
239
240 pub fn behind(&self, count: u32) -> Style {
242 if count > 0 {
243 self.fg(self.palette.red)
244 } else {
245 self.fg(self.palette.gray)
246 }
247 }
248
249 pub fn commit_hash(&self) -> Style {
251 self.fg(self.palette.orange)
252 }
253
254 pub fn time(&self) -> Style {
256 self.fg(self.palette.gray)
257 }
258
259 pub fn branch(&self, is_current: bool, is_detached: bool) -> Style {
261 if is_detached {
262 self.fg(self.palette.yellow)
263 } else if is_current {
264 self.styled(self.palette.accent, Modifier::BOLD)
265 } else {
266 Style::default()
267 }
268 }
269
270 pub fn pr_state(&self, state: PrState) -> Style {
272 let color = match state {
273 PrState::Open => self.palette.green,
274 PrState::Draft => self.palette.gray,
275 PrState::Merged => self.palette.magenta,
276 PrState::Closed => self.palette.red,
277 };
278 self.fg(color)
279 }
280
281 pub fn selection(&self) -> Style {
284 if self.enabled {
285 Style::default()
286 .bg(self.palette.selection_bg)
287 .add_modifier(Modifier::BOLD)
288 } else {
289 Style::default().add_modifier(Modifier::REVERSED)
290 }
291 }
292
293 pub fn selection_symbol(&self) -> &'static str {
295 if self.enabled { "▌ " } else { "> " }
296 }
297
298 pub fn mode_chip(&self, mode: &Mode) -> Style {
300 if !self.enabled {
301 return Style::default().add_modifier(Modifier::REVERSED);
302 }
303 let color = match mode {
304 Mode::List => self.palette.accent,
305 Mode::Filter => self.palette.yellow,
306 Mode::Create(_) => self.palette.green,
307 Mode::PrPicker(_) => self.palette.magenta,
308 Mode::PrCompose(_) => self.palette.green,
309 Mode::Checkout(_) => self.palette.accent,
310 Mode::ConfirmRemove(_) => self.palette.red,
311 Mode::ConfirmCreate(_) => self.palette.green,
312 Mode::ConfirmDeleteBranch { .. } => self.palette.red,
313 Mode::ConfirmStaleBase(_) => self.palette.yellow,
314 Mode::ConfirmInitSubmodules(_) => self.palette.green,
315 Mode::ConfirmQuit { .. } => self.palette.red,
316 Mode::Help => self.palette.cyan,
317 };
318 Style::default()
319 .bg(color)
320 .fg(self.palette.chip_fg)
321 .add_modifier(Modifier::BOLD)
322 }
323
324 pub fn border(&self, focused: bool) -> Style {
326 match (self.enabled, focused) {
327 (true, true) => Style::default().fg(self.palette.accent),
328 (true, false) => Style::default().fg(self.palette.gray),
329 (false, true) => Style::default(),
330 (false, false) => Style::default().add_modifier(Modifier::DIM),
331 }
332 }
333
334 pub fn title(&self, focused: bool) -> Style {
336 match (self.enabled, focused) {
337 (true, true) => Style::default()
338 .fg(self.palette.accent)
339 .add_modifier(Modifier::BOLD),
340 (true, false) => Style::default().fg(self.palette.gray),
341 (false, true) => Style::default().add_modifier(Modifier::BOLD),
342 (false, false) => Style::default().add_modifier(Modifier::DIM),
343 }
344 }
345
346 pub fn hint_key(&self) -> Style {
348 self.styled(self.palette.accent, Modifier::BOLD)
349 }
350
351 pub fn hint_label(&self) -> Style {
353 self.fg(self.palette.gray)
354 }
355
356 pub fn label(&self) -> Style {
358 self.fg(self.palette.gray)
359 }
360
361 pub fn accent(&self) -> Style {
363 self.fg(self.palette.accent)
364 }
365
366 pub fn url(&self) -> Style {
368 if self.enabled {
369 Style::default()
370 .fg(self.palette.accent)
371 .add_modifier(Modifier::UNDERLINED)
372 } else {
373 Style::default()
374 }
375 }
376
377 pub fn status(&self, kind: StatusKind) -> Style {
379 match kind {
380 StatusKind::Success => self.fg(self.palette.green),
381 StatusKind::Error => self.fg(self.palette.red),
382 StatusKind::Info => Style::default(),
383 }
384 }
385
386 pub fn error(&self) -> Style {
388 self.fg(self.palette.red)
389 }
390
391 pub fn warning(&self) -> Style {
393 self.fg(self.palette.yellow)
394 }
395
396 pub fn success(&self) -> Style {
398 self.fg(self.palette.green)
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn enabled_applies_color_disabled_is_plain() {
408 let on = Theme::new(true);
409 let off = Theme::new(false);
410 assert!(on.enabled());
411 assert_eq!(on.current().fg, Some(GREEN));
412 assert!(on.current().add_modifier.contains(Modifier::BOLD));
413 assert_eq!(off.current(), Style::default());
415 assert_eq!(off.commit_hash(), Style::default());
416 }
417
418 #[test]
419 fn ahead_behind_mute_at_zero() {
420 let t = Theme::new(true);
421 assert_eq!(t.ahead(2).fg, Some(GREEN));
422 assert_eq!(t.ahead(0).fg, Some(GRAY));
423 assert_eq!(t.behind(3).fg, Some(RED));
424 assert_eq!(t.behind(0).fg, Some(GRAY));
425 }
426
427 #[test]
428 fn pr_state_colors() {
429 let t = Theme::new(true);
430 assert_eq!(t.pr_state(PrState::Open).fg, Some(GREEN));
431 assert_eq!(t.pr_state(PrState::Draft).fg, Some(GRAY));
432 assert_eq!(t.pr_state(PrState::Merged).fg, Some(MAGENTA));
433 assert_eq!(t.pr_state(PrState::Closed).fg, Some(RED));
434 }
435
436 #[test]
437 fn branch_role_styling() {
438 let t = Theme::new(true);
439 assert_eq!(t.branch(false, true).fg, Some(YELLOW)); assert_eq!(t.branch(true, false).fg, Some(ACCENT)); assert_eq!(t.branch(false, false), Style::default()); assert_eq!(t.branchless().fg, Some(GRAY));
444 assert_eq!(Theme::new(false).branchless(), Style::default());
445 }
446
447 #[test]
448 fn selection_uses_bg_or_reversed() {
449 assert_eq!(Theme::new(true).selection().bg, Some(SELECTION_BG));
450 assert!(
451 Theme::new(false)
452 .selection()
453 .add_modifier
454 .contains(Modifier::REVERSED)
455 );
456 assert_eq!(Theme::new(true).selection_symbol(), "▌ ");
457 assert_eq!(Theme::new(false).selection_symbol(), "> ");
458 }
459
460 #[test]
461 fn mode_chip_colors_per_mode() {
462 let t = Theme::new(true);
463 assert_eq!(t.mode_chip(&Mode::List).bg, Some(ACCENT));
464 assert_eq!(t.mode_chip(&Mode::Filter).bg, Some(YELLOW));
465 assert_eq!(t.mode_chip(&Mode::Help).bg, Some(CYAN));
466 assert_eq!(t.mode_chip(&Mode::ConfirmRemove(0)).bg, Some(RED));
467 assert_eq!(t.mode_chip(&Mode::ConfirmCreate(0)).bg, Some(GREEN));
468 assert!(
470 Theme::new(false)
471 .mode_chip(&Mode::List)
472 .add_modifier
473 .contains(Modifier::REVERSED)
474 );
475 }
476
477 #[test]
478 fn focus_changes_border_and_title() {
479 let t = Theme::new(true);
480 assert_eq!(t.border(true).fg, Some(ACCENT));
481 assert_eq!(t.border(false).fg, Some(GRAY));
482 assert_eq!(t.title(true).fg, Some(ACCENT));
483 assert!(t.title(true).add_modifier.contains(Modifier::BOLD));
484 let off = Theme::new(false);
486 assert!(off.title(true).add_modifier.contains(Modifier::BOLD));
487 assert!(off.border(false).add_modifier.contains(Modifier::DIM));
488 }
489
490 #[test]
491 fn status_severity_colors() {
492 let t = Theme::new(true);
493 assert_eq!(t.status(StatusKind::Success).fg, Some(GREEN));
494 assert_eq!(t.status(StatusKind::Error).fg, Some(RED));
495 assert_eq!(t.status(StatusKind::Info), Style::default());
496 }
497
498 #[test]
499 fn preset_parse_and_id_round_trip() {
500 assert_eq!(ThemePreset::parse("one-dark"), Some(ThemePreset::OneDark));
501 assert_eq!(
502 ThemePreset::parse("solarized"),
503 Some(ThemePreset::Solarized)
504 );
505 assert_eq!(ThemePreset::parse("nope"), None);
506 assert_eq!(ThemePreset::OneDark.id(), "one-dark");
507 assert_eq!(ThemePreset::Solarized.id(), "solarized");
508 assert_eq!(ThemePreset::default(), ThemePreset::OneDark);
510 assert_eq!(ThemePreset::OneDark.palette(), Palette::one_dark());
511 }
512
513 #[test]
514 fn one_dark_palette_matches_legacy_constants() {
515 let p = Palette::one_dark();
516 assert_eq!(p.accent, ACCENT);
517 assert_eq!(p.green, GREEN);
518 assert_eq!(p.red, RED);
519 assert_eq!(p.yellow, YELLOW);
520 assert_eq!(p.orange, ORANGE);
521 assert_eq!(p.cyan, CYAN);
522 assert_eq!(p.magenta, MAGENTA);
523 assert_eq!(p.gray, GRAY);
524 assert_eq!(p.selection_bg, SELECTION_BG);
525 assert_eq!(p.chip_fg, CHIP_FG);
526 assert_eq!(Palette::default(), p);
528 }
529
530 #[test]
531 fn with_palette_applies_custom_colors() {
532 let mut p = Palette::one_dark();
534 p.green = Color::Rgb(1, 2, 3);
535 p.accent = Color::Rgb(4, 5, 6);
536 let t = Theme::with_palette(true, p);
537 assert_eq!(t.current().fg, Some(Color::Rgb(1, 2, 3)));
538 assert_eq!(t.border(true).fg, Some(Color::Rgb(4, 5, 6)));
539 let sol = Palette::solarized();
541 assert_ne!(sol.accent, ACCENT);
542 assert_ne!(sol.green, GREEN);
543 assert_eq!(Theme::with_palette(false, p).current(), Style::default());
545 }
546}