Skip to main content

wt/tui/
theme.rs

1//! Truecolor theme for the TUI (spec §10/§11): maps worktree and UI state to
2//! ratatui [`Style`]s.
3//!
4//! All color is gated behind a single `enabled` flag — resolved once from the
5//! `--color` flag, `NO_COLOR`, and `ui.color` (spec §11 precedence) — so that
6//! disabling color collapses cleanly to the monochrome look (only the
7//! structural `DIM`/`BOLD`/`REVERSED` modifiers remain). The concrete colors
8//! come from a [`Palette`]: a built-in [`ThemePreset`] (One-Dark by default) with
9//! per-color overrides applied on top (`[ui.theme]`, spec §11). Each palette is
10//! fixed 24-bit RGB, so it renders consistently regardless of the terminal's own
11//! theme.
12
13use ratatui::style::{Color, Modifier, Style};
14
15use crate::model::PrState;
16use crate::tui::app::{Mode, StatusKind};
17
18/// Accent (focus, links, selection bar) — blue.
19const ACCENT: Color = Color::Rgb(97, 175, 239);
20/// Good/current/ahead/open — green.
21const GREEN: Color = Color::Rgb(152, 195, 121);
22/// Bad/behind/missing/closed — red.
23const RED: Color = Color::Rgb(224, 108, 117);
24/// Dirty/detached/warning — yellow.
25const YELLOW: Color = Color::Rgb(229, 192, 123);
26/// Commit hash — orange.
27const ORANGE: Color = Color::Rgb(209, 154, 102);
28/// Untracked — cyan.
29const CYAN: Color = Color::Rgb(86, 182, 194);
30/// Merged PR — magenta.
31const MAGENTA: Color = Color::Rgb(198, 120, 221);
32/// Muted text (absent marker, spinner, relative time, labels) — gray.
33const GRAY: Color = Color::Rgb(92, 99, 112);
34/// Selected-row background.
35const SELECTION_BG: Color = Color::Rgb(62, 68, 81);
36/// Foreground for text drawn on a colored chip (the mode label).
37const CHIP_FG: Color = Color::Rgb(30, 33, 39);
38
39/// The set of semantic colors a [`Theme`] draws from. Every field is a concrete
40/// 24-bit (or named) [`Color`]; [`Theme`] decides where each is applied.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Palette {
43    /// Accent (focus, links, selection bar).
44    pub accent: Color,
45    /// Good/current/ahead/open.
46    pub green: Color,
47    /// Bad/behind/missing/closed.
48    pub red: Color,
49    /// Dirty/detached/warning.
50    pub yellow: Color,
51    /// Commit hash.
52    pub orange: Color,
53    /// Untracked.
54    pub cyan: Color,
55    /// Merged PR.
56    pub magenta: Color,
57    /// Muted text (absent marker, spinner, relative time, labels).
58    pub gray: Color,
59    /// Selected-row background.
60    pub selection_bg: Color,
61    /// Foreground for text drawn on a colored chip (the mode label).
62    pub chip_fg: Color,
63}
64
65impl Palette {
66    /// The default One-Dark-style palette.
67    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    /// The Solarized-dark palette.
83    pub fn solarized() -> Palette {
84        Palette {
85            accent: Color::Rgb(38, 139, 210),    // blue
86            green: Color::Rgb(133, 153, 0),      // green
87            red: Color::Rgb(220, 50, 47),        // red
88            yellow: Color::Rgb(181, 137, 0),     // yellow
89            orange: Color::Rgb(203, 75, 22),     // orange
90            cyan: Color::Rgb(42, 161, 152),      // cyan
91            magenta: Color::Rgb(211, 54, 130),   // magenta
92            gray: Color::Rgb(88, 110, 117),      // base01
93            selection_bg: Color::Rgb(7, 54, 66), // base02
94            chip_fg: Color::Rgb(0, 43, 54),      // base03
95        }
96    }
97}
98
99impl Default for Palette {
100    fn default() -> Self {
101        Palette::one_dark()
102    }
103}
104
105/// A built-in base palette, selected by `ui.theme.preset` (spec §11).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
107pub enum ThemePreset {
108    /// One-Dark-style (the default).
109    #[default]
110    OneDark,
111    /// Solarized-dark.
112    Solarized,
113}
114
115impl ThemePreset {
116    /// Parses a preset identifier (`one-dark`/`solarized`), or `None` if unknown.
117    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    /// The stable identifier for this preset (the `config get` form).
126    pub fn id(self) -> &'static str {
127        match self {
128            ThemePreset::OneDark => "one-dark",
129            ThemePreset::Solarized => "solarized",
130        }
131    }
132
133    /// The base [`Palette`] for this preset.
134    pub fn palette(self) -> Palette {
135        match self {
136            ThemePreset::OneDark => Palette::one_dark(),
137            ThemePreset::Solarized => Palette::solarized(),
138        }
139    }
140}
141
142/// A resolved theme. Construct with [`Theme::new`] (One-Dark) or
143/// [`Theme::with_palette`]; every accessor returns a ratatui [`Style`] that is
144/// plain ([`Style::default`]) when color is disabled.
145pub struct Theme {
146    enabled: bool,
147    palette: Palette,
148}
149
150impl Theme {
151    /// Builds a theme over the default (One-Dark) palette. When `enabled` is
152    /// false every color accessor returns a plain style, preserving the
153    /// monochrome (`NO_COLOR`) appearance.
154    pub fn new(enabled: bool) -> Theme {
155        Theme {
156            enabled,
157            palette: Palette::one_dark(),
158        }
159    }
160
161    /// Builds a theme over a specific [`Palette`] (the configured one). Color is
162    /// still gated by `enabled`.
163    pub fn with_palette(enabled: bool, palette: Palette) -> Theme {
164        Theme { enabled, palette }
165    }
166
167    /// Whether color is enabled (some widgets adjust their fallback styling).
168    pub fn enabled(&self) -> bool {
169        self.enabled
170    }
171
172    /// A foreground color, or a plain style when color is disabled.
173    fn fg(&self, color: Color) -> Style {
174        if self.enabled {
175            Style::default().fg(color)
176        } else {
177            Style::default()
178        }
179    }
180
181    /// A foreground color plus a modifier, or a plain style when disabled.
182    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    /// Style for the current-worktree marker (`*`/▸).
191    pub fn current(&self) -> Style {
192        self.styled(self.palette.green, Modifier::BOLD)
193    }
194
195    /// Style for the missing-worktree marker (`!`/✘).
196    pub fn missing(&self) -> Style {
197        self.fg(self.palette.red)
198    }
199
200    /// Style for the detached-HEAD marker (`~`/⚓).
201    pub fn detached(&self) -> Style {
202        self.fg(self.palette.yellow)
203    }
204
205    /// Style for the worktree-less branch marker (`○`): muted, since a branch row
206    /// is secondary to the real worktrees above it (issue #47).
207    pub fn branchless(&self) -> Style {
208        self.fg(self.palette.gray)
209    }
210
211    /// Style for the dirty marker (`M`/●).
212    pub fn dirty(&self) -> Style {
213        self.fg(self.palette.yellow)
214    }
215
216    /// Style for the untracked marker (`?`).
217    pub fn untracked(&self) -> Style {
218        self.fg(self.palette.cyan)
219    }
220
221    /// Style for the "field unavailable" placeholder (`–`).
222    pub fn absent(&self) -> Style {
223        self.fg(self.palette.gray)
224    }
225
226    /// Style for the per-field loading spinner (`…`).
227    pub fn spinner(&self) -> Style {
228        self.fg(self.palette.gray)
229    }
230
231    /// Style for the ahead count (`↑N`): green when ahead, muted at zero.
232    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    /// Style for the behind count (`↓N`): red when behind, muted at zero.
241    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    /// Style for a commit short hash.
250    pub fn commit_hash(&self) -> Style {
251        self.fg(self.palette.orange)
252    }
253
254    /// Style for a relative timestamp.
255    pub fn time(&self) -> Style {
256        self.fg(self.palette.gray)
257    }
258
259    /// Style for a branch name, by role.
260    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    /// Style for a PR's number/state cell, by PR state.
271    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    /// The selected-row highlight: a background bar when color is enabled (so the
282    /// per-field foreground colors stay readable), reversed video otherwise.
283    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    /// The left-bar highlight symbol for the selected row.
294    pub fn selection_symbol(&self) -> &'static str {
295        if self.enabled { "▌ " } else { "> " }
296    }
297
298    /// The status-bar mode chip, colored per mode (reversed when disabled).
299    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    /// A pane border style; the focused pane is accented, others muted.
325    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    /// A pane title style; the focused pane is accented/bold, others muted.
335    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    /// Style for a key token in the status-bar / help key hints.
347    pub fn hint_key(&self) -> Style {
348        self.styled(self.palette.accent, Modifier::BOLD)
349    }
350
351    /// Style for the description text next to a key hint.
352    pub fn hint_label(&self) -> Style {
353        self.fg(self.palette.gray)
354    }
355
356    /// Style for a detail-pane field label.
357    pub fn label(&self) -> Style {
358        self.fg(self.palette.gray)
359    }
360
361    /// The accent style (active fields, prompts).
362    pub fn accent(&self) -> Style {
363        self.fg(self.palette.accent)
364    }
365
366    /// Style for a clickable/URL value.
367    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    /// Style for a transient status message, by severity.
378    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    /// Style for error text in modals.
387    pub fn error(&self) -> Style {
388        self.fg(self.palette.red)
389    }
390
391    /// Style for warning text in modals.
392    pub fn warning(&self) -> Style {
393        self.fg(self.palette.yellow)
394    }
395
396    /// Style for a reassuring/positive note (e.g. "merged — safe to delete").
397    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        // Disabled returns a plain style: no fg, no bold.
414        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)); // detached
440        assert_eq!(t.branch(true, false).fg, Some(ACCENT)); // current
441        assert_eq!(t.branch(false, false), Style::default()); // plain
442        // The worktree-less branch marker is muted (issue #47).
443        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        // Disabled falls back to reversed video.
469        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        // Monochrome still conveys focus via bold vs dim.
485        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        // The default preset is One-Dark and matches the legacy constants.
509        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        // `Default` is One-Dark.
527        assert_eq!(Palette::default(), p);
528    }
529
530    #[test]
531    fn with_palette_applies_custom_colors() {
532        // A custom palette flows through the semantic accessors.
533        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        // Solarized differs from One-Dark on every primary slot.
540        let sol = Palette::solarized();
541        assert_ne!(sol.accent, ACCENT);
542        assert_ne!(sol.green, GREEN);
543        // Color still gates: disabled is plain regardless of palette.
544        assert_eq!(Theme::with_palette(false, p).current(), Style::default());
545    }
546}