Skip to main content

gwm/tui/
theme.rs

1//! Configurable TUI theme (issue #33).
2//!
3//! Role-based colours: every visual signal in the TUI maps to a
4//! semantic role (`focus`, `accent`, `branch`, `clean`, `dirty`,
5//! `main`, `locked`, `prunable`, `muted`, `selection_bg`, `name`,
6//! `path`, `staged`, `modified`, `untracked`) rather than a
7//! hard-coded `Color::Cyan`. Users override roles in `.gwm.toml`:
8//!
9//! ```toml
10//! [theme]
11//! preset = "catppuccin"
12//! focus  = "#89b4fa"   # mocha blue override on top of the preset
13//! ```
14//!
15//! Three layers, in order of authority:
16//!
17//! 1. [`Theme::default()`] — the pre-#33 hardcoded scheme. Users
18//!    who omit `[theme]` see no change.
19//! 2. [`Theme::preset(name)`] — built-in palette. The preset
20//!    replaces every role; partial presets are not supported.
21//! 3. [`Theme::apply_override`] — per-role override fed from
22//!    `[theme]` entries. Overrides land on top of (1) or (2).
23//!
24//! Colour values accept three forms:
25//!   - Named (`cyan`, `Cyan`, `dark_gray`, `bright_blue`) — case-
26//!     insensitive.
27//!   - 256-palette index (`0`..=`255`).
28//!   - Hex (`#89b4fa`, `#0ff` short form is **not** supported in
29//!     v1; the parser refuses to guess).
30//!
31//! Errors surface as [`GwmError::Config`] so the config loader can
32//! attribute them to the right TOML coordinate.
33
34use crate::error::{GwmError, Result};
35use ratatui::style::Color;
36
37// ---------------------------------------------------------------------------
38// Theme struct
39// ---------------------------------------------------------------------------
40
41/// Role-based colour scheme for the TUI. Every field is a
42/// [`ratatui::style::Color`]; the renderer reads them via
43/// `App.theme` rather than hard-coding palette values.
44///
45/// Order of fields here matches the documented `[theme]` key order
46/// in `examples/gwm.toml.example` so the doc and the struct stay
47/// readable side-by-side.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct Theme {
50  /// Focused border / cursor / active overlay highlight. Pre-#33
51  /// hardcoded as `Color::Cyan`.
52  pub focus: Color,
53  /// General accent: header title, key hints in the help overlay,
54  /// the palette input bar prompt. Pre-#33 hardcoded as `Cyan`.
55  pub accent: Color,
56  /// Branch name in lists and the sidebar identity card. Pre-#33
57  /// hardcoded as `Green` when the branch is in a healthy state.
58  pub branch: Color,
59  /// "Working tree is clean" status indicator. Pre-#33 `Green`.
60  pub clean: Color,
61  /// "Working tree is dirty" status indicator. Pre-#33 `Yellow`.
62  pub dirty: Color,
63  /// Main / trunk worktree badge. Pre-#33 `Yellow`.
64  pub main: Color,
65  /// Locked worktree badge (`🔒`). Pre-#33 `Magenta`.
66  pub locked: Color,
67  /// Prunable worktree badge (`⚠`). Pre-#33 `Red`.
68  pub prunable: Color,
69  /// De-emphasised text: hints, footers, placeholders. Pre-#33
70  /// `DarkGray`.
71  pub muted: Color,
72  /// Selection highlight background. Pre-#33 `DarkGray`. Kept as a
73  /// separate role so themes that prefer a coloured selection
74  /// background (e.g. catppuccin's surface tone) can move it
75  /// independently of `muted`.
76  pub selection_bg: Color,
77  /// Primary identity text: the worktree *name* in the table and the
78  /// sidebar header, plus the `Issue #N` / `PR #N` heads of the link
79  /// summary lines. Pre-#170 these were a hard-coded `Color::White`
80  /// with no semantic role (issue #210). Rendered bold at the name
81  /// sites; the role only carries the foreground colour.
82  pub name: Color,
83  /// Worktree path column in the table. Pre-#170 a hard-coded
84  /// `Color::Gray`, distinct from `muted` (`DarkGray`) so the path
85  /// reads as a structural mid-grey rather than dimmed text (issue
86  /// #210). The sidebar identity-card path stays on `muted` and is
87  /// intentionally *not* moved here — doing so would shift its
88  /// default appearance.
89  pub path: Color,
90  /// Staged (index-side) git-status changes in the working-tree panel
91  /// — the `X` column and a staged-only file name. Pre-#211 this
92  /// borrowed `accent`; the dedicated role (default `Cyan`, issue
93  /// #211) decouples a staged file from the focus/accent highlight.
94  pub staged: Color,
95  /// Worktree-side git-status modifications — the `Y` column and a
96  /// modified file name. Pre-#211 this borrowed `dirty` (a branch
97  /// divergence warning); the dedicated role (default `Yellow`,
98  /// issue #211) decouples the two unrelated signals.
99  pub modified: Color,
100  /// Untracked / created git-status entries (`??`). Pre-#211 this
101  /// borrowed `clean` (the "working tree is clean" indicator); the
102  /// dedicated role (default `Green`, issue #211) decouples them.
103  pub untracked: Color,
104}
105
106impl Default for Theme {
107  /// Pre-#33 hardcoded scheme. Documented field-by-field above. A
108  /// non-default look (e.g. the Claude orange) is opt-in via a
109  /// `[theme] preset = "claude-dark"` block — not baked into the
110  /// default, so users who never write a `[theme]` block see no change.
111  fn default() -> Self {
112    Self {
113      focus: Color::Cyan,
114      accent: Color::Cyan,
115      branch: Color::Green,
116      clean: Color::Green,
117      dirty: Color::Yellow,
118      main: Color::Yellow,
119      locked: Color::Magenta,
120      prunable: Color::Red,
121      muted: Color::DarkGray,
122      selection_bg: Color::DarkGray,
123      name: Color::White,
124      path: Color::Gray,
125      staged: Color::Cyan,
126      modified: Color::Yellow,
127      untracked: Color::Green,
128    }
129  }
130}
131
132impl Theme {
133  /// Resolve a built-in preset by name. Returns `None` for unknown
134  /// names — the config loader translates `None` into a
135  /// `GwmError::Config` with the candidate name + the [`preset_names`]
136  /// list for the user to crib from.
137  pub fn preset(name: &str) -> Option<Self> {
138    match name {
139      "catppuccin" | "catppuccin-mocha" => Some(Self::catppuccin_mocha()),
140      "gruvbox" | "gruvbox-dark" => Some(Self::gruvbox_dark()),
141      "tokyo-night" | "tokyonight" => Some(Self::tokyo_night()),
142      "claude-dark" | "claude" => Some(Self::claude_dark()),
143      _ => None,
144    }
145  }
146
147  /// Claude dark palette, ported from the Anthropic "Pure Dark"
148  /// reference scheme. The signature orange (`#D4825D` primary,
149  /// `#C15F3C` for focused borders) drives focus/accent; the semantic
150  /// colours map green→branch/clean, yellow→dirty/main, purple→locked,
151  /// red→prunable, and the warm overlay greys to muted/selection.
152  fn claude_dark() -> Self {
153    Self {
154      focus: Color::Rgb(0xC1, 0x5F, 0x3C),        // Orange Dark (focused borders)
155      accent: Color::Rgb(0xD4, 0x82, 0x5D),       // Orange (primary accent)
156      branch: Color::Rgb(0x86, 0xE8, 0x9A),       // Success green
157      clean: Color::Rgb(0x86, 0xE8, 0x9A),        // Success green
158      dirty: Color::Rgb(0xFF, 0xDF, 0x61),        // Warning yellow
159      main: Color::Rgb(0xFF, 0xDF, 0x61),         // Warning yellow
160      locked: Color::Rgb(0xC7, 0x9B, 0xFF),       // Special purple
161      prunable: Color::Rgb(0xFF, 0x7A, 0x7A),     // Error red
162      muted: Color::Rgb(0x99, 0x99, 0x99),        // Text muted
163      selection_bg: Color::Rgb(0x38, 0x38, 0x38), // Surface 1 (active)
164      name: Color::Rgb(0xE0, 0xE0, 0xE0),         // --text (primary text)
165      path: Color::Rgb(0xB0, 0xB0, 0xB0),         // --text-dim (secondary text / Subtext 0)
166      staged: Color::Rgb(0xD4, 0x82, 0x5D),       // = accent (preserves the borrowed look)
167      modified: Color::Rgb(0xFF, 0xDF, 0x61),     // = dirty
168      untracked: Color::Rgb(0x86, 0xE8, 0x9A),    // = clean
169    }
170  }
171
172  /// Catppuccin Mocha palette. Hex values taken from the upstream
173  /// flavour reference (`https://github.com/catppuccin/catppuccin`).
174  fn catppuccin_mocha() -> Self {
175    Self {
176      focus: Color::Rgb(0x89, 0xb4, 0xfa),        // Blue
177      accent: Color::Rgb(0xcb, 0xa6, 0xf7),       // Mauve
178      branch: Color::Rgb(0xa6, 0xe3, 0xa1),       // Green
179      clean: Color::Rgb(0xa6, 0xe3, 0xa1),        // Green
180      dirty: Color::Rgb(0xf9, 0xe2, 0xaf),        // Yellow
181      main: Color::Rgb(0xf9, 0xe2, 0xaf),         // Yellow
182      locked: Color::Rgb(0xcb, 0xa6, 0xf7),       // Mauve
183      prunable: Color::Rgb(0xf3, 0x8b, 0xa8),     // Red
184      muted: Color::Rgb(0x6c, 0x70, 0x86),        // Overlay 0
185      selection_bg: Color::Rgb(0x31, 0x32, 0x44), // Surface 0
186      name: Color::Rgb(0xcd, 0xd6, 0xf4),         // Text
187      path: Color::Rgb(0xa6, 0xad, 0xc8),         // Subtext 0
188      staged: Color::Rgb(0xcb, 0xa6, 0xf7),       // = accent / Mauve
189      modified: Color::Rgb(0xf9, 0xe2, 0xaf),     // = dirty / Yellow
190      untracked: Color::Rgb(0xa6, 0xe3, 0xa1),    // = clean / Green
191    }
192  }
193
194  /// Gruvbox dark palette (medium contrast).
195  fn gruvbox_dark() -> Self {
196    Self {
197      focus: Color::Rgb(0x83, 0xa5, 0x98),        // Bright blue
198      accent: Color::Rgb(0xfa, 0xbd, 0x2f),       // Bright yellow
199      branch: Color::Rgb(0xb8, 0xbb, 0x26),       // Bright green
200      clean: Color::Rgb(0xb8, 0xbb, 0x26),        // Bright green
201      dirty: Color::Rgb(0xfa, 0xbd, 0x2f),        // Bright yellow
202      main: Color::Rgb(0xfe, 0x80, 0x19),         // Bright orange
203      locked: Color::Rgb(0xd3, 0x86, 0x9b),       // Bright purple
204      prunable: Color::Rgb(0xfb, 0x49, 0x34),     // Bright red
205      muted: Color::Rgb(0x92, 0x83, 0x74),        // Light4
206      selection_bg: Color::Rgb(0x3c, 0x38, 0x36), // Dark1
207      name: Color::Rgb(0xeb, 0xdb, 0xb2),         // fg / Light1
208      path: Color::Rgb(0xa8, 0x99, 0x84),         // Light4 (brighter than the muted gray)
209      staged: Color::Rgb(0xfa, 0xbd, 0x2f),       // = accent / Bright yellow
210      modified: Color::Rgb(0xfa, 0xbd, 0x2f),     // = dirty / Bright yellow
211      untracked: Color::Rgb(0xb8, 0xbb, 0x26),    // = clean / Bright green
212    }
213  }
214
215  /// Tokyo Night palette (storm variant).
216  fn tokyo_night() -> Self {
217    Self {
218      focus: Color::Rgb(0x7a, 0xa2, 0xf7),        // Blue
219      accent: Color::Rgb(0xbb, 0x9a, 0xf7),       // Purple
220      branch: Color::Rgb(0x9e, 0xce, 0x6a),       // Green
221      clean: Color::Rgb(0x9e, 0xce, 0x6a),        // Green
222      dirty: Color::Rgb(0xe0, 0xaf, 0x68),        // Orange
223      main: Color::Rgb(0xe0, 0xaf, 0x68),         // Orange
224      locked: Color::Rgb(0xbb, 0x9a, 0xf7),       // Purple
225      prunable: Color::Rgb(0xf7, 0x76, 0x8e),     // Red
226      muted: Color::Rgb(0x56, 0x5f, 0x89),        // Comment
227      selection_bg: Color::Rgb(0x33, 0x3a, 0x55), // Selection
228      name: Color::Rgb(0xc0, 0xca, 0xf5),         // fg
229      path: Color::Rgb(0x73, 0x7a, 0xa2),         // dark5 (brighter than comment muted)
230      staged: Color::Rgb(0xbb, 0x9a, 0xf7),       // = accent / Purple
231      modified: Color::Rgb(0xe0, 0xaf, 0x68),     // = dirty / Orange
232      untracked: Color::Rgb(0x9e, 0xce, 0x6a),    // = clean / Green
233    }
234  }
235
236  /// Apply a single role override. Returns `Err` when `role` is
237  /// unknown or `value` does not parse as a color. On `Ok` the
238  /// targeted field is mutated in place.
239  pub fn apply_override(&mut self, role: &str, value: &str) -> Result<()> {
240    // Unwrap an inner `GwmError::Config` before re-wrapping so the
241    // user-visible error reads `config error: theme.<role>: …`
242    // rather than the duplicated `config error: theme.<role>:
243    // config error: …` that the naive `format!("…: {}", e)` would
244    // produce — the inner Display already carries the "config
245    // error:" prefix. Same pattern as `Config::validate_labels` /
246    // `validate_tui_keys` use elsewhere.
247    let color = parse_color(value).map_err(|e| {
248      let inner = match e {
249        GwmError::Config(msg) => msg,
250        other => other.to_string(),
251      };
252      GwmError::Config(format!("theme.{}: {}", role, inner))
253    })?;
254    let slot = self
255      .role_mut(role)
256      .ok_or_else(|| GwmError::Config(format!("theme: unknown role {:?}", role)))?;
257    *slot = color;
258    Ok(())
259  }
260
261  fn role_mut(&mut self, role: &str) -> Option<&mut Color> {
262    match role {
263      "focus" => Some(&mut self.focus),
264      "accent" => Some(&mut self.accent),
265      "branch" => Some(&mut self.branch),
266      "clean" => Some(&mut self.clean),
267      "dirty" => Some(&mut self.dirty),
268      "main" => Some(&mut self.main),
269      "locked" => Some(&mut self.locked),
270      "prunable" => Some(&mut self.prunable),
271      "muted" => Some(&mut self.muted),
272      "selection_bg" => Some(&mut self.selection_bg),
273      "name" => Some(&mut self.name),
274      "path" => Some(&mut self.path),
275      "staged" => Some(&mut self.staged),
276      "modified" => Some(&mut self.modified),
277      "untracked" => Some(&mut self.untracked),
278      _ => None,
279    }
280  }
281}
282
283/// Names of every built-in preset. Surfaced by `gwm theme list`.
284pub fn preset_names() -> &'static [&'static str] {
285  &["catppuccin", "gruvbox", "tokyo-night", "claude-dark"]
286}
287
288// ---------------------------------------------------------------------------
289// Color parsing
290// ---------------------------------------------------------------------------
291
292/// Parse a colour string in any of the three documented forms
293/// (named, indexed, hex). Returns `Err` with a user-facing message
294/// on garbage input.
295pub fn parse_color(s: &str) -> Result<Color> {
296  let trimmed = s.trim();
297  if trimmed.is_empty() {
298    return Err(GwmError::Config("empty color string".into()));
299  }
300  // Hex form: `#RRGGBB` (six hex digits + leading `#`). Short
301  // (#RGB) form is intentionally not supported — the parser refuses
302  // to guess on a 3-char user input.
303  if let Some(stripped) = trimmed.strip_prefix('#') {
304    if stripped.len() != 6 || !stripped.chars().all(|c| c.is_ascii_hexdigit()) {
305      return Err(GwmError::Config(format!(
306        "invalid hex color {:?} (expected #RRGGBB)",
307        s
308      )));
309    }
310    let r = u8::from_str_radix(&stripped[0..2], 16).unwrap();
311    let g = u8::from_str_radix(&stripped[2..4], 16).unwrap();
312    let b = u8::from_str_radix(&stripped[4..6], 16).unwrap();
313    return Ok(Color::Rgb(r, g, b));
314  }
315  // Indexed form: bare digits `0..=255`.
316  if trimmed.chars().all(|c| c.is_ascii_digit()) {
317    let n: u16 = trimmed
318      .parse()
319      .map_err(|_| GwmError::Config(format!("invalid indexed color {:?}", s)))?;
320    if n > 255 {
321      return Err(GwmError::Config(format!("indexed color {} out of range (0..=255)", n)));
322    }
323    return Ok(Color::Indexed(n as u8));
324  }
325  // Named form: case-insensitive match against the ratatui color
326  // enum. Centralised here so a new ratatui release that adds a
327  // colour does not require touching every call site.
328  let lower = trimmed.to_ascii_lowercase();
329  match lower.as_str() {
330    "black" => Ok(Color::Black),
331    "red" => Ok(Color::Red),
332    "green" => Ok(Color::Green),
333    "yellow" => Ok(Color::Yellow),
334    "blue" => Ok(Color::Blue),
335    "magenta" => Ok(Color::Magenta),
336    "cyan" => Ok(Color::Cyan),
337    "gray" | "grey" => Ok(Color::Gray),
338    "dark_gray" | "darkgray" | "dark_grey" | "darkgrey" => Ok(Color::DarkGray),
339    "light_red" | "lightred" | "bright_red" | "brightred" => Ok(Color::LightRed),
340    "light_green" | "lightgreen" | "bright_green" | "brightgreen" => Ok(Color::LightGreen),
341    "light_yellow" | "lightyellow" | "bright_yellow" | "brightyellow" => Ok(Color::LightYellow),
342    "light_blue" | "lightblue" | "bright_blue" | "brightblue" => Ok(Color::LightBlue),
343    "light_magenta" | "lightmagenta" | "bright_magenta" | "brightmagenta" => Ok(Color::LightMagenta),
344    "light_cyan" | "lightcyan" | "bright_cyan" | "brightcyan" => Ok(Color::LightCyan),
345    "white" => Ok(Color::White),
346    "reset" => Ok(Color::Reset),
347    _ => Err(GwmError::Config(format!(
348      "unknown color name {:?} (try `cyan` / `#89b4fa` / `220`)",
349      s
350    ))),
351  }
352}