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}