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 rgb_of(color: Color) -> (u8, u8, u8) {
64 let code = match color {
65 Color::Rgb(r, g, b) => return (r, g, b),
66 Color::Indexed(code) => code,
67 _ => return (0, 0, 0),
68 };
69 match code {
70 16..232 => {
72 let level = |c: u8| if c == 0 { 0 } else { 55 + c * 40 };
73 let base = code - 16;
74 (level(base / 36), level((base % 36) / 6), level(base % 6))
75 }
76 232.. => {
78 let v = 8 + (code - 232) * 10;
79 (v, v, v)
80 }
81 _ => (0, 0, 0),
82 }
83}
84
85pub struct Theme {
86 pub accent: Color,
87 high_contrast_selection: bool,
88 colors_disabled: bool,
89 light_background: bool,
90}
91
92impl Theme {
93 pub fn new(name: &str) -> Self {
94 let colors_disabled = std::env::var_os("NO_COLOR").is_some()
95 || std::env::var("TERM").is_ok_and(|term| term == "dumb");
96 let light = terminal_background_is_light();
97 Self::with_environment(name, colors_disabled, light)
98 }
99
100 pub fn with_environment(name: &str, colors_disabled: bool, light_background: bool) -> Self {
101 let accent = if colors_disabled {
102 Color::Reset
103 } else if light_background {
104 Color::Indexed(match name {
105 "red" => 124,
106 "yellow" => 136,
107 "green" => 28,
108 "cyan" => 30,
109 "blue" => 25,
110 "purple" => 91,
111 "white" => 238,
112 "black" => 16,
113 _ => 25,
114 })
115 } else {
116 color(name)
117 };
118 Self {
119 accent,
120 high_contrast_selection: colors_disabled || light_background,
121 colors_disabled,
122 light_background,
123 }
124 }
125
126 pub fn muted_color(&self) -> Color {
127 if self.colors_disabled {
128 Color::Reset
129 } else {
130 GREY
131 }
132 }
133
134 pub fn error_color(&self) -> Color {
135 if self.colors_disabled {
136 Color::Reset
137 } else {
138 RED
139 }
140 }
141
142 pub fn success_color(&self) -> Color {
143 if self.colors_disabled {
144 Color::Reset
145 } else {
146 GREEN
147 }
148 }
149
150 pub fn selection_wash(&self) -> Color {
151 if self.colors_disabled {
152 Color::Reset
153 } else {
154 tint(self.accent)
155 }
156 }
157
158 pub fn dimmed_accent(&self) -> Color {
159 if self.colors_disabled {
160 Color::Reset
161 } else {
162 dimmed(self.accent)
163 }
164 }
165
166 pub fn selection(&self) -> Style {
169 if self.high_contrast_selection {
170 Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
171 } else {
172 Style::new()
173 .bg(self.selection_wash())
174 .add_modifier(Modifier::BOLD)
175 }
176 }
177
178 pub fn selection_unfocused(&self) -> Style {
182 if self.high_contrast_selection {
183 Style::new().add_modifier(Modifier::REVERSED)
184 } else {
185 Style::new().bg(self.selection_wash())
186 }
187 }
188
189 pub fn accent_text(&self) -> Style {
190 Style::new().fg(self.accent)
191 }
192
193 fn label_color(&self, color: LabelColor) -> Color {
194 Color::Indexed(if self.light_background {
195 match color {
196 LabelColor::Red => 124,
197 LabelColor::Orange => 166,
198 LabelColor::Yellow => 136,
199 LabelColor::Lime => 64,
200 LabelColor::Green => 28,
201 LabelColor::Teal => 29,
202 LabelColor::Cyan => 30,
203 LabelColor::Blue => 25,
204 LabelColor::Indigo => 55,
205 LabelColor::Purple => 91,
206 LabelColor::Pink => 125,
207 LabelColor::Brown => 94,
208 }
209 } else {
210 match color {
211 LabelColor::Red => 196,
212 LabelColor::Orange => 208,
213 LabelColor::Yellow => 226,
214 LabelColor::Lime => 118,
215 LabelColor::Green => 41,
216 LabelColor::Teal => 36,
217 LabelColor::Cyan => 45,
218 LabelColor::Blue => 39,
219 LabelColor::Indigo => 63,
220 LabelColor::Purple => 141,
221 LabelColor::Pink => 205,
222 LabelColor::Brown => 130,
223 }
224 })
225 }
226
227 fn completed_label_color(&self) -> Color {
228 Color::Indexed(if self.light_background { 238 } else { 244 })
229 }
230
231 pub fn label_badge(&self, color: LabelColor, done: bool) -> Style {
234 if self.colors_disabled {
235 return Style::new().add_modifier(Modifier::REVERSED);
236 }
237 let background = if done {
238 self.completed_label_color()
239 } else {
240 self.label_color(color)
241 };
242 let (r, g, b) = rgb_of(background);
243 let luminance = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1_000;
244 let foreground = if luminance >= 128 {
245 Color::Indexed(16)
246 } else {
247 Color::Indexed(231)
248 };
249 Style::new()
250 .fg(foreground)
251 .bg(background)
252 .remove_modifier(Modifier::REVERSED)
253 }
254
255 pub fn label_swatch(&self, color: LabelColor) -> Style {
258 if self.colors_disabled {
259 Style::new()
260 } else {
261 Style::new()
262 .fg(self.label_color(color))
263 .remove_modifier(Modifier::REVERSED)
264 }
265 }
266
267 pub fn label_focus(&self) -> Style {
269 if self.colors_disabled {
270 Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
271 } else {
272 Style::new()
273 .bg(Color::Indexed(if self.light_background {
274 250
275 } else {
276 240
277 }))
278 .add_modifier(Modifier::BOLD)
279 }
280 }
281
282 pub fn plain(&self) -> Style {
283 Style::new()
284 }
285}
286
287fn terminal_background_is_light() -> bool {
288 let Ok(value) = std::env::var("COLORFGBG") else {
289 return false;
290 };
291 value
292 .split([';', ':'])
293 .next_back()
294 .and_then(|value| value.parse::<u8>().ok())
295 .is_some_and(|background| matches!(background, 7 | 10..=15))
296}
297
298pub fn reduced_motion() -> bool {
299 std::env::var("MACH_REDUCED_MOTION").is_ok_and(|value| {
300 matches!(
301 value.to_ascii_lowercase().as_str(),
302 "1" | "true" | "yes" | "on"
303 )
304 })
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn no_color_and_light_terminals_use_contrast_instead_of_rgb_washes() {
313 let no_color = Theme::with_environment("cyan", true, false);
314 assert_eq!(no_color.accent, Color::Reset);
315 assert!(
316 no_color
317 .selection()
318 .add_modifier
319 .contains(Modifier::REVERSED)
320 );
321 assert_eq!(no_color.muted_color(), Color::Reset);
322 assert_eq!(no_color.error_color(), Color::Reset);
323 assert_eq!(no_color.success_color(), Color::Reset);
324 assert_eq!(no_color.selection_wash(), Color::Reset);
325 assert_eq!(no_color.dimmed_accent(), Color::Reset);
326 assert!(
327 no_color
328 .label_badge(LabelColor::Blue, false)
329 .add_modifier
330 .contains(Modifier::REVERSED)
331 );
332
333 let light = Theme::with_environment("white", false, true);
334 assert_eq!(light.accent, Color::Indexed(238));
335 assert!(light.selection().add_modifier.contains(Modifier::REVERSED));
336 assert_eq!(
337 light.label_badge(LabelColor::Brown, false).bg,
338 Some(Color::Indexed(94))
339 );
340 assert_eq!(
341 light.label_badge(LabelColor::Brown, false).fg,
342 Some(Color::Indexed(231))
343 );
344 assert_eq!(
345 light.label_badge(LabelColor::Red, true).bg,
346 Some(Color::Indexed(238))
347 );
348 let dark_yellow = Theme::with_environment("yellow", false, false);
349 assert_eq!(
350 dark_yellow.label_badge(LabelColor::Yellow, false).bg,
351 Some(Color::Indexed(226))
352 );
353 assert_eq!(
354 dark_yellow.label_badge(LabelColor::Yellow, false).fg,
355 Some(Color::Indexed(16))
356 );
357 assert_eq!(
358 dark_yellow.label_badge(LabelColor::Red, true).bg,
359 Some(Color::Indexed(244))
360 );
361 assert_eq!(
362 dark_yellow.label_badge(LabelColor::Blue, true).bg,
363 Some(Color::Indexed(244)),
364 "completed task labels share one neutral background"
365 );
366 assert_eq!(dark_yellow.label_focus().bg, Some(Color::Indexed(240)));
367 }
368}