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 control(&self) -> Style {
289 if self.colors_disabled {
290 Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
291 } else {
292 let background = Color::Indexed(if self.light_background { 250 } else { 240 });
293 Style::new()
294 .fg(contrasting_text(background))
295 .bg(background)
296 .add_modifier(Modifier::BOLD)
297 }
298 }
299
300 pub fn control_hover(&self) -> Style {
302 if self.colors_disabled {
303 self.hover().add_modifier(Modifier::BOLD)
304 } else {
305 let background = hover_tint(self.accent, self.light_background);
306 Style::new()
307 .fg(contrasting_text(background))
308 .bg(background)
309 .add_modifier(Modifier::BOLD)
310 .remove_modifier(Modifier::REVERSED)
311 }
312 }
313
314 pub fn label_focus(&self) -> Style {
316 if self.colors_disabled {
317 Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
318 } else {
319 Style::new()
320 .bg(Color::Indexed(if self.light_background {
321 250
322 } else {
323 240
324 }))
325 .add_modifier(Modifier::BOLD)
326 }
327 }
328
329 pub fn hover(&self) -> Style {
333 if self.colors_disabled {
334 Style::new().add_modifier(Modifier::DIM | Modifier::REVERSED)
335 } else {
336 Style::new().bg(hover_tint(self.accent, self.light_background))
337 }
338 }
339
340 pub fn label_hover(&self) -> Style {
343 if self.colors_disabled {
344 self.hover()
345 } else {
346 let background = hover_tint(self.accent, self.light_background);
347 Style::new()
348 .fg(contrasting_text(background))
349 .bg(background)
350 .remove_modifier(Modifier::REVERSED)
351 }
352 }
353
354 pub fn plain(&self) -> Style {
355 Style::new()
356 }
357}
358
359fn terminal_background_is_light() -> bool {
360 let Ok(value) = std::env::var("COLORFGBG") else {
361 return false;
362 };
363 value
364 .split([';', ':'])
365 .next_back()
366 .and_then(|value| value.parse::<u8>().ok())
367 .is_some_and(|background| matches!(background, 7 | 10..=15))
368}
369
370pub fn reduced_motion() -> bool {
371 std::env::var("MACH_REDUCED_MOTION").is_ok_and(|value| {
372 matches!(
373 value.to_ascii_lowercase().as_str(),
374 "1" | "true" | "yes" | "on"
375 )
376 })
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn no_color_and_light_terminals_use_contrast_instead_of_rgb_washes() {
385 let no_color = Theme::with_environment("cyan", true, false);
386 assert_eq!(no_color.accent, Color::Reset);
387 assert!(
388 no_color
389 .selection()
390 .add_modifier
391 .contains(Modifier::REVERSED)
392 );
393 assert_eq!(no_color.muted_color(), Color::Reset);
394 assert_eq!(no_color.error_color(), Color::Reset);
395 assert_eq!(no_color.success_color(), Color::Reset);
396 assert_eq!(no_color.selection_wash(), Color::Reset);
397 assert_eq!(no_color.dimmed_accent(), Color::Reset);
398 assert!(
399 no_color
400 .label_badge(LabelColor::Blue, false)
401 .add_modifier
402 .contains(Modifier::REVERSED)
403 );
404 assert!(
405 no_color
406 .hover()
407 .add_modifier
408 .contains(Modifier::DIM | Modifier::REVERSED),
409 "NO_COLOR still needs a background-like hover state"
410 );
411 assert!(!no_color.hover().add_modifier.contains(Modifier::BOLD));
412 assert!(!no_color.hover().add_modifier.contains(Modifier::UNDERLINED));
413 assert_eq!(no_color.hover().bg, None);
414 assert!(no_color.control().add_modifier.contains(Modifier::REVERSED));
415
416 let light = Theme::with_environment("white", false, true);
417 assert_eq!(light.accent, Color::Indexed(238));
418 assert!(light.selection().add_modifier.contains(Modifier::REVERSED));
419 assert_eq!(
420 light.label_badge(LabelColor::Brown, false).bg,
421 Some(Color::Indexed(94))
422 );
423 assert_eq!(
424 light.label_badge(LabelColor::Brown, false).fg,
425 Some(Color::Indexed(231))
426 );
427 assert_eq!(
428 light.label_badge(LabelColor::Red, true).bg,
429 Some(Color::Indexed(238))
430 );
431 assert!(light.hover().bg.is_some());
432 assert!(!light.hover().add_modifier.contains(Modifier::UNDERLINED));
433 assert_eq!(light.control().bg, Some(Color::Indexed(250)));
434 let dark_yellow = Theme::with_environment("yellow", false, false);
435 assert!(dark_yellow.hover().bg.is_some());
436 assert!(
437 !dark_yellow
438 .hover()
439 .add_modifier
440 .contains(Modifier::UNDERLINED)
441 );
442 assert_eq!(
443 dark_yellow.label_badge(LabelColor::Yellow, false).bg,
444 Some(Color::Indexed(226))
445 );
446 assert_eq!(
447 dark_yellow.label_badge(LabelColor::Yellow, false).fg,
448 Some(Color::Indexed(16))
449 );
450 assert_eq!(
451 dark_yellow.label_badge(LabelColor::Red, true).bg,
452 Some(Color::Indexed(244))
453 );
454 assert_eq!(
455 dark_yellow.label_badge(LabelColor::Blue, true).bg,
456 Some(Color::Indexed(244)),
457 "completed task labels share one neutral background"
458 );
459 assert_eq!(dark_yellow.label_focus().bg, Some(Color::Indexed(240)));
460 assert_eq!(dark_yellow.control().bg, Some(Color::Indexed(240)));
461 }
462}