1use ratatui::style::{Color, Modifier, Style};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Palette {
13 pub name: &'static str,
14 pub text: Color,
15 pub subtle: Color,
16 pub muted: Color,
17 pub surface: Color,
19 pub border: Color,
20 pub accent: Color,
21 pub accent2: Color,
22 pub success: Color,
23 pub warning: Color,
24 pub error: Color,
25 pub info: Color,
26 pub ink: Color,
28 pub truecolor: bool,
29}
30
31const fn rgb(hex: u32) -> Color {
32 Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
33}
34
35impl Palette {
36 pub const GESTALT: Self = Self {
38 name: "gestalt",
39 text: rgb(0xcdd6f4),
40 subtle: rgb(0xa6adc8),
41 muted: rgb(0x6c7086),
42 surface: rgb(0x313244),
43 border: rgb(0x45475a),
44 accent: rgb(0xb4befe),
45 accent2: rgb(0xcba6f7),
46 success: rgb(0xa6e3a1),
47 warning: rgb(0xf9e2af),
48 error: rgb(0xf38ba8),
49 info: rgb(0x89b4fa),
50 ink: rgb(0x11111b),
51 truecolor: true,
52 };
53
54 pub const LATTE: Self = Self {
56 name: "latte",
57 text: rgb(0x4c4f69),
58 subtle: rgb(0x6c6f85),
59 muted: rgb(0x9ca0b0),
60 surface: rgb(0xccd0da),
61 border: rgb(0xbcc0cc),
62 accent: rgb(0x7287fd),
63 accent2: rgb(0x8839ef),
64 success: rgb(0x40a02b),
65 warning: rgb(0xdf8e1d),
66 error: rgb(0xd20f39),
67 info: rgb(0x1e66f5),
68 ink: rgb(0xeff1f5),
69 truecolor: true,
70 };
71
72 pub const NORD: Self = Self {
73 name: "nord",
74 text: rgb(0xeceff4),
75 subtle: rgb(0xd8dee9),
76 muted: rgb(0x616e88),
77 surface: rgb(0x3b4252),
78 border: rgb(0x434c5e),
79 accent: rgb(0x88c0d0),
80 accent2: rgb(0x81a1c1),
81 success: rgb(0xa3be8c),
82 warning: rgb(0xebcb8b),
83 error: rgb(0xbf616a),
84 info: rgb(0x5e81ac),
85 ink: rgb(0x2e3440),
86 truecolor: true,
87 };
88
89 pub const DRACULA: Self = Self {
90 name: "dracula",
91 text: rgb(0xf8f8f2),
92 subtle: rgb(0xd6d6d0),
93 muted: rgb(0x6272a4),
94 surface: rgb(0x44475a),
95 border: rgb(0x44475a),
96 accent: rgb(0xbd93f9),
97 accent2: rgb(0xff79c6),
98 success: rgb(0x50fa7b),
99 warning: rgb(0xf1fa8c),
100 error: rgb(0xff5555),
101 info: rgb(0x8be9fd),
102 ink: rgb(0x282a36),
103 truecolor: true,
104 };
105
106 pub const ANSI: Self = Self {
108 name: "ansi",
109 text: Color::Reset,
110 subtle: Color::Gray,
111 muted: Color::DarkGray,
112 surface: Color::DarkGray,
113 border: Color::DarkGray,
114 accent: Color::Cyan,
115 accent2: Color::Magenta,
116 success: Color::Green,
117 warning: Color::Yellow,
118 error: Color::Red,
119 info: Color::Blue,
120 ink: Color::Black,
121 truecolor: false,
122 };
123
124 pub const ALL: [Self; 5] = [
126 Self::GESTALT,
127 Self::LATTE,
128 Self::NORD,
129 Self::DRACULA,
130 Self::ANSI,
131 ];
132
133 pub fn named(name: &str) -> Option<Self> {
135 match name {
136 "dark" => Some(Self::GESTALT),
137 "light" => Some(Self::LATTE),
138 _ => Self::ALL.into_iter().find(|palette| palette.name == name),
139 }
140 }
141}
142
143fn supports_truecolor() -> bool {
145 std::env::var("COLORTERM").is_ok_and(|value| value == "truecolor" || value == "24bit")
146 || std::env::var("TERM_PROGRAM").is_ok_and(|value| {
147 matches!(
148 value.as_str(),
149 "iTerm.app" | "WezTerm" | "ghostty" | "vscode" | "WarpTerminal"
150 )
151 })
152}
153
154#[derive(Debug, Clone)]
156pub struct Theme {
157 pub name: &'static str,
160
161 pub background: Color,
163 pub foreground: Color,
164 pub muted: Color,
165
166 pub selected: Style,
168 pub hover: Style,
169
170 pub size_huge: Color, pub size_large: Color, pub size_medium: Color, pub size_small: Color, pub size_tiny: Color, pub success: Color,
179 pub warning: Color,
180 pub error: Color,
181 pub info: Color,
182 pub ink: Color,
184
185 pub border: Style,
187 pub title: Style,
188 pub help_key: Style,
189 pub help_desc: Style,
190
191 pub tree_lines: Style,
193 pub directory: Style,
194 pub file: Style,
195 pub symlink: Style,
196 pub executable: Style,
197
198 pub header: Style,
200 pub footer: Style,
201
202 pub help_key_bg: Color,
204 pub help_key_fg: Color,
205 pub help_desc_fg: Color,
206 pub footer_bg: Color,
207
208 pub git_modified: Color,
210 pub git_staged: Color,
211 pub git_untracked: Color,
212 pub git_ignored: Color,
213 pub git_conflict: Color,
214}
215
216impl Theme {
217 pub fn named(name: &str) -> Option<Self> {
220 let palette = Palette::named(name)?;
221 let shown = if palette.truecolor && !supports_truecolor() {
222 Palette::ANSI
223 } else {
224 palette
225 };
226 Some(Self {
227 name: palette.name,
228 ..Self::from_palette(shown)
229 })
230 }
231
232 pub fn dark() -> Self {
234 Self::from_palette(Palette::GESTALT)
235 }
236
237 pub fn next(&self) -> Self {
239 let index = Palette::ALL
240 .iter()
241 .position(|palette| palette.name == self.name)
242 .map_or(0, |index| (index + 1) % Palette::ALL.len());
243 Self::named(Palette::ALL[index].name).unwrap_or_else(Self::dark)
244 }
245
246 pub fn from_palette(palette: Palette) -> Self {
248 let p = palette;
249 Self {
250 name: palette.name,
251 background: Color::Reset,
252 foreground: p.text,
253 muted: p.muted,
254
255 selected: Style::new()
256 .bg(p.surface)
257 .fg(p.text)
258 .add_modifier(Modifier::BOLD),
259 hover: Style::new().bg(p.surface),
260
261 size_huge: p.error,
262 size_large: p.warning,
263 size_medium: p.success,
264 size_small: p.info,
265 size_tiny: p.muted,
266
267 success: p.success,
268 warning: p.warning,
269 error: p.error,
270 info: p.info,
271 ink: p.ink,
272
273 border: Style::new().fg(p.border),
274 title: Style::new().fg(p.accent).add_modifier(Modifier::BOLD),
275 help_key: Style::new().fg(p.accent).add_modifier(Modifier::BOLD),
276 help_desc: Style::new().fg(p.subtle),
277
278 tree_lines: Style::new().fg(p.muted),
279 directory: Style::new().fg(p.info).add_modifier(Modifier::BOLD),
280 file: Style::new().fg(p.text),
281 symlink: Style::new().fg(p.accent2),
282 executable: Style::new().fg(p.success),
283
284 header: Style::new().bg(p.surface).fg(p.text),
285 footer: Style::new().bg(p.surface).fg(p.subtle),
286
287 help_key_bg: p.border,
288 help_key_fg: p.accent,
289 help_desc_fg: p.subtle,
290 footer_bg: p.surface,
291
292 git_modified: p.warning,
293 git_staged: p.success,
294 git_untracked: p.accent2,
295 git_ignored: p.muted,
296 git_conflict: p.error,
297 }
298 }
299
300 pub fn size_color(&self, ratio: f64) -> Color {
302 match ratio {
303 r if r > 0.50 => self.size_huge,
304 r if r > 0.25 => self.size_large,
305 r if r > 0.10 => self.size_medium,
306 r if r > 0.01 => self.size_small,
307 _ => self.size_tiny,
308 }
309 }
310
311 pub fn size_bar_style(&self, ratio: f64) -> Style {
313 Style::new().fg(self.size_color(ratio))
314 }
315
316 pub fn help_key_style(&self) -> Style {
318 Style::new()
319 .bg(self.help_key_bg)
320 .fg(self.help_key_fg)
321 .add_modifier(Modifier::BOLD)
322 }
323
324 pub fn help_desc_style(&self) -> Style {
326 Style::new().fg(self.help_desc_fg)
327 }
328
329 pub fn footer_style(&self) -> Style {
331 Style::new().bg(self.footer_bg)
332 }
333
334 pub fn git_status_color(&self, status: gravityfile_core::GitStatus) -> Color {
336 use gravityfile_core::GitStatus;
337 match status {
338 GitStatus::Modified => self.git_modified,
339 GitStatus::Staged => self.git_staged,
340 GitStatus::Untracked => self.git_untracked,
341 GitStatus::Ignored => self.git_ignored,
342 GitStatus::Conflict => self.git_conflict,
343 GitStatus::Clean => self.muted,
344 }
345 }
346}
347
348impl Default for Theme {
349 fn default() -> Self {
350 Self::dark()
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn every_palette_is_reachable_by_name_and_by_cycling() {
360 for palette in Palette::ALL {
361 assert_eq!(Palette::named(palette.name), Some(palette));
362 }
363 assert_eq!(Palette::named("dark"), Some(Palette::GESTALT));
364 assert_eq!(Palette::named("light"), Some(Palette::LATTE));
365 assert_eq!(Palette::named("solarized"), None);
366
367 let mut theme = Theme::from_palette(Palette::ANSI);
368 let mut seen = Vec::new();
369 for _ in 0..Palette::ALL.len() {
370 theme = theme.next();
371 seen.push(theme.name);
372 }
373 let names: Vec<_> = Palette::ALL.iter().map(|palette| palette.name).collect();
375 assert_eq!(seen, names);
376 }
377
378 #[test]
379 fn backgrounds_are_the_terminals_own() {
380 for palette in Palette::ALL {
381 assert_eq!(Theme::from_palette(palette).background, Color::Reset);
382 }
383 }
384}