1use ratatui::style::{Color, Modifier, Style};
4
5use crate::model::LabelColor;
6
7pub fn color(name: &str) -> Color {
8 Color::Indexed(match name {
9 "red" => 160,
10 "yellow" => 226,
11 "green" => 41,
12 "cyan" => 37,
13 "blue" => 39,
14 "purple" => 141,
15 "white" => 231,
16 "black" => 234,
17 "grey" => 244,
18 _ => 39,
19 })
20}
21
22pub const RED: Color = Color::Indexed(196);
23pub const GREEN: Color = Color::Indexed(41);
24pub const GREY: Color = Color::Indexed(244);
25
26pub fn dimmed(c: Color) -> Color {
29 let Color::Indexed(code) = c else { return c };
30 let dim = match code {
31 0..=15 => {
32 if code >= 8 {
33 code - 8
34 } else {
35 code
36 }
37 }
38 232.. => code.saturating_sub(4).max(232),
39 _ => {
40 let base = code - 16;
41 let (r, g, b) = (base / 36, (base % 36) / 6, base % 6);
42 16 + 36 * r.saturating_sub(1) + 6 * g.saturating_sub(1) + b.saturating_sub(1)
43 }
44 };
45 Color::Indexed(dim)
46}
47
48pub fn tint(color: Color) -> Color {
57 let (r, g, b) = rgb_of(color);
58 let wash = |c: u8| (c / 4).saturating_add(8);
59 Color::Rgb(wash(r), wash(g), wash(b))
60}
61
62fn hover_tint(color: Color, light_background: bool) -> Color {
66 let (r, g, b) = rgb_of(color);
67 if light_background {
68 let pale = |channel: u8| ((u16::from(channel) + 5 * 255) / 6) as u8;
69 Color::Rgb(pale(r), pale(g), pale(b))
70 } else {
71 let (r, g, b) = rgb_of(tint(color));
72 let soften = |channel: u8| ((u16::from(channel) + 32) / 2) as u8;
73 Color::Rgb(soften(r), soften(g), soften(b))
74 }
75}
76
77fn contrasting_text(background: Color) -> Color {
78 let (r, g, b) = rgb_of(background);
79 let luminance = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1_000;
80 if luminance >= 128 {
81 Color::Indexed(16)
82 } else {
83 Color::Indexed(231)
84 }
85}
86
87fn rgb_of(color: Color) -> (u8, u8, u8) {
89 let code = match color {
90 Color::Rgb(r, g, b) => return (r, g, b),
91 Color::Indexed(code) => code,
92 _ => return (0, 0, 0),
93 };
94 match code {
95 16..232 => {
97 let level = |c: u8| if c == 0 { 0 } else { 55 + c * 40 };
98 let base = code - 16;
99 (level(base / 36), level((base % 36) / 6), level(base % 6))
100 }
101 232.. => {
103 let v = 8 + (code - 232) * 10;
104 (v, v, v)
105 }
106 _ => (0, 0, 0),
107 }
108}
109
110pub struct Theme {
111 pub accent: Color,
112 colors_disabled: bool,
113 light_background: bool,
114}
115
116impl Theme {
117 pub fn new(name: &str) -> Self {
118 let colors_disabled = std::env::var_os("NO_COLOR").is_some()
119 || std::env::var("TERM").is_ok_and(|term| term == "dumb");
120 let light = terminal_background_is_light();
121 Self::with_environment(name, colors_disabled, light)
122 }
123
124 pub fn with_environment(name: &str, colors_disabled: bool, light_background: bool) -> Self {
125 let accent = if colors_disabled {
126 Color::Reset
127 } else if light_background {
128 Color::Indexed(match name {
129 "red" => 124,
130 "yellow" => 136,
131 "green" => 28,
132 "cyan" => 30,
133 "blue" => 25,
134 "purple" => 91,
135 "white" => 238,
136 "black" => 16,
137 _ => 25,
138 })
139 } else {
140 color(name)
141 };
142 Self {
143 accent,
144 colors_disabled,
145 light_background,
146 }
147 }
148
149 pub fn muted_color(&self) -> Color {
150 if self.colors_disabled {
151 Color::Reset
152 } else {
153 GREY
154 }
155 }
156
157 pub fn error_color(&self) -> Color {
158 if self.colors_disabled {
159 Color::Reset
160 } else {
161 RED
162 }
163 }
164
165 pub fn success_color(&self) -> Color {
166 if self.colors_disabled {
167 Color::Reset
168 } else {
169 GREEN
170 }
171 }
172
173 pub fn selection_wash(&self) -> Color {
174 if self.colors_disabled {
175 Color::Reset
176 } else {
177 tint(self.accent)
178 }
179 }
180
181 pub fn dimmed_accent(&self) -> Color {
182 if self.colors_disabled {
183 Color::Reset
184 } else {
185 dimmed(self.accent)
186 }
187 }
188
189 fn high_contrast_selection(&self) -> bool {
190 self.colors_disabled || self.light_background
191 }
192
193 pub fn selection(&self) -> Style {
196 if self.high_contrast_selection() {
197 Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
198 } else {
199 Style::new()
200 .bg(self.selection_wash())
201 .add_modifier(Modifier::BOLD)
202 }
203 }
204
205 pub fn selection_unfocused(&self) -> Style {
209 if self.high_contrast_selection() {
210 Style::new().add_modifier(Modifier::REVERSED)
211 } else {
212 Style::new().bg(self.selection_wash())
213 }
214 }
215
216 pub fn accent_text(&self) -> Style {
217 Style::new().fg(self.accent)
218 }
219
220 fn label_color(&self, color: LabelColor) -> Color {
221 Color::Indexed(if self.light_background {
222 match color {
223 LabelColor::Red => 124,
224 LabelColor::Orange => 166,
225 LabelColor::Yellow => 136,
226 LabelColor::Lime => 64,
227 LabelColor::Green => 28,
228 LabelColor::Teal => 29,
229 LabelColor::Cyan => 30,
230 LabelColor::Blue => 25,
231 LabelColor::Indigo => 55,
232 LabelColor::Purple => 91,
233 LabelColor::Pink => 125,
234 LabelColor::Brown => 94,
235 }
236 } else {
237 match color {
238 LabelColor::Red => 196,
239 LabelColor::Orange => 208,
240 LabelColor::Yellow => 226,
241 LabelColor::Lime => 118,
242 LabelColor::Green => 41,
243 LabelColor::Teal => 36,
244 LabelColor::Cyan => 45,
245 LabelColor::Blue => 39,
246 LabelColor::Indigo => 63,
247 LabelColor::Purple => 141,
248 LabelColor::Pink => 205,
249 LabelColor::Brown => 130,
250 }
251 })
252 }
253
254 fn completed_label_color(&self) -> Color {
255 Color::Indexed(if self.light_background { 238 } else { 244 })
256 }
257
258 pub fn label_badge(&self, color: LabelColor, done: bool) -> Style {
261 if self.colors_disabled {
262 return Style::new().add_modifier(Modifier::REVERSED);
263 }
264 let background = if done {
265 self.completed_label_color()
266 } else {
267 self.label_color(color)
268 };
269 Style::new()
270 .fg(contrasting_text(background))
271 .bg(background)
272 .remove_modifier(Modifier::REVERSED)
273 }
274
275 pub fn label_swatch(&self, color: LabelColor) -> Style {
278 if self.colors_disabled {
279 Style::new()
280 } else {
281 Style::new()
282 .fg(self.label_color(color))
283 .remove_modifier(Modifier::REVERSED)
284 }
285 }
286
287 pub fn label_focus(&self) -> Style {
289 if self.colors_disabled {
290 Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
291 } else {
292 Style::new()
293 .bg(Color::Indexed(if self.light_background {
294 250
295 } else {
296 240
297 }))
298 .add_modifier(Modifier::BOLD)
299 }
300 }
301
302 pub fn hover(&self) -> Style {
306 if self.colors_disabled {
307 Style::new().add_modifier(Modifier::DIM | Modifier::REVERSED)
308 } else {
309 Style::new().bg(hover_tint(self.accent, self.light_background))
310 }
311 }
312
313 pub fn label_hover(&self) -> Style {
316 if self.colors_disabled {
317 self.hover()
318 } else {
319 let background = hover_tint(self.accent, self.light_background);
320 Style::new()
321 .fg(contrasting_text(background))
322 .bg(background)
323 .remove_modifier(Modifier::REVERSED)
324 }
325 }
326
327 pub fn plain(&self) -> Style {
328 Style::new()
329 }
330}
331
332fn terminal_background_is_light() -> bool {
333 let Ok(value) = std::env::var("COLORFGBG") else {
334 return false;
335 };
336 value
337 .split([';', ':'])
338 .next_back()
339 .and_then(|value| value.parse::<u8>().ok())
340 .is_some_and(|background| matches!(background, 7 | 10..=15))
341}
342
343pub fn reduced_motion() -> bool {
344 std::env::var("MACH_REDUCED_MOTION").is_ok_and(|value| {
345 matches!(
346 value.to_ascii_lowercase().as_str(),
347 "1" | "true" | "yes" | "on"
348 )
349 })
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn no_color_and_light_terminals_use_contrast_instead_of_rgb_washes() {
358 let no_color = Theme::with_environment("cyan", true, false);
359 assert_eq!(no_color.accent, Color::Reset);
360 assert!(
361 no_color
362 .selection()
363 .add_modifier
364 .contains(Modifier::REVERSED)
365 );
366 assert_eq!(no_color.muted_color(), Color::Reset);
367 assert_eq!(no_color.error_color(), Color::Reset);
368 assert_eq!(no_color.success_color(), Color::Reset);
369 assert_eq!(no_color.selection_wash(), Color::Reset);
370 assert_eq!(no_color.dimmed_accent(), Color::Reset);
371 assert!(
372 no_color
373 .label_badge(LabelColor::Blue, false)
374 .add_modifier
375 .contains(Modifier::REVERSED)
376 );
377 assert!(
378 no_color
379 .hover()
380 .add_modifier
381 .contains(Modifier::DIM | Modifier::REVERSED),
382 "NO_COLOR still needs a background-like hover state"
383 );
384 assert!(!no_color.hover().add_modifier.contains(Modifier::BOLD));
385 assert!(!no_color.hover().add_modifier.contains(Modifier::UNDERLINED));
386 assert_eq!(no_color.hover().bg, None);
387
388 let light = Theme::with_environment("white", false, true);
389 assert_eq!(light.accent, Color::Indexed(238));
390 assert!(light.selection().add_modifier.contains(Modifier::REVERSED));
391 assert_eq!(
392 light.label_badge(LabelColor::Brown, false).bg,
393 Some(Color::Indexed(94))
394 );
395 assert_eq!(
396 light.label_badge(LabelColor::Brown, false).fg,
397 Some(Color::Indexed(231))
398 );
399 assert_eq!(
400 light.label_badge(LabelColor::Red, true).bg,
401 Some(Color::Indexed(238))
402 );
403 assert!(light.hover().bg.is_some());
404 assert!(!light.hover().add_modifier.contains(Modifier::UNDERLINED));
405 let dark_yellow = Theme::with_environment("yellow", false, false);
406 assert!(dark_yellow.hover().bg.is_some());
407 assert!(
408 !dark_yellow
409 .hover()
410 .add_modifier
411 .contains(Modifier::UNDERLINED)
412 );
413 assert_eq!(
414 dark_yellow.label_badge(LabelColor::Yellow, false).bg,
415 Some(Color::Indexed(226))
416 );
417 assert_eq!(
418 dark_yellow.label_badge(LabelColor::Yellow, false).fg,
419 Some(Color::Indexed(16))
420 );
421 assert_eq!(
422 dark_yellow.label_badge(LabelColor::Red, true).bg,
423 Some(Color::Indexed(244))
424 );
425 assert_eq!(
426 dark_yellow.label_badge(LabelColor::Blue, true).bg,
427 Some(Color::Indexed(244)),
428 "completed task labels share one neutral background"
429 );
430 assert_eq!(dark_yellow.label_focus().bg, Some(Color::Indexed(240)));
431 }
432}