Skip to main content

gravityfile_tui/
theme.rs

1//! Colour themes.
2//!
3//! A theme is built from a small semantic [`Palette`], as in carwash, so both
4//! tools offer the same named themes. Backgrounds stay the terminal's own,
5//! and truecolour palettes fall back to the terminal's 16 colours when it
6//! cannot show 24-bit colour.
7
8use ratatui::style::{Color, Modifier, Style};
9
10/// The semantic colours a theme is built from.
11#[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    /// Highlighted rows, header and footer.
18    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    /// Text drawn on a cell filled with a status or size colour.
27    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    /// Catppuccin Mocha.
37    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    /// Catppuccin Latte, for light terminals.
55    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    /// The terminal's own 16 colours.
107    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    /// Every palette, in the order `t` cycles through them.
125    pub const ALL: [Self; 5] = [
126        Self::GESTALT,
127        Self::LATTE,
128        Self::NORD,
129        Self::DRACULA,
130        Self::ANSI,
131    ];
132
133    /// The palette called `name`; `dark` and `light` name the defaults.
134    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
143/// Whether the terminal advertises 24-bit colour.
144fn 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/// Styles for every part of the interface, derived from a [`Palette`].
155#[derive(Debug, Clone)]
156pub struct Theme {
157    /// The theme asked for, which `t` cycles from even when the terminal
158    /// shows it as `ansi`.
159    pub name: &'static str,
160
161    // Base colors
162    pub background: Color,
163    pub foreground: Color,
164    pub muted: Color,
165
166    // Interactive elements
167    pub selected: Style,
168    pub hover: Style,
169
170    // Size indicators (gradient by proportion)
171    pub size_huge: Color,   // > 50%
172    pub size_large: Color,  // > 25%
173    pub size_medium: Color, // > 10%
174    pub size_small: Color,  // > 1%
175    pub size_tiny: Color,   // <= 1%
176
177    // Status colors
178    pub success: Color,
179    pub warning: Color,
180    pub error: Color,
181    pub info: Color,
182    /// Text on a cell filled with a status or size colour.
183    pub ink: Color,
184
185    // UI elements
186    pub border: Style,
187    pub title: Style,
188    pub help_key: Style,
189    pub help_desc: Style,
190
191    // Tree elements
192    pub tree_lines: Style,
193    pub directory: Style,
194    pub file: Style,
195    pub symlink: Style,
196    pub executable: Style,
197
198    // Header/Footer
199    pub header: Style,
200    pub footer: Style,
201
202    // Footer keybinding hints (pill/badge style)
203    pub help_key_bg: Color,
204    pub help_key_fg: Color,
205    pub help_desc_fg: Color,
206    pub footer_bg: Color,
207
208    // Git status colors
209    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    /// The theme called `name`, if there is one, adjusted to the terminal:
218    /// a truecolour palette becomes `ansi` where 24-bit colour is missing.
219    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    /// The default dark theme, Gestalt.
233    pub fn dark() -> Self {
234        Self::from_palette(Palette::GESTALT)
235    }
236
237    /// The next theme in [`Palette::ALL`].
238    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    /// Every style, derived from `palette`.
247    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    /// Get color for a size ratio (0.0 to 1.0).
301    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    /// Get style for a size bar at given ratio.
312    pub fn size_bar_style(&self, ratio: f64) -> Style {
313        Style::new().fg(self.size_color(ratio))
314    }
315
316    /// Style for keybinding keys in footer (pill/badge style).
317    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    /// Style for keybinding descriptions in footer.
325    pub fn help_desc_style(&self) -> Style {
326        Style::new().fg(self.help_desc_fg)
327    }
328
329    /// Style for footer background.
330    pub fn footer_style(&self) -> Style {
331        Style::new().bg(self.footer_bg)
332    }
333
334    /// Get color for a git status.
335    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        // Even where every palette shows as ansi, cycling visits them all.
374        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}