Skip to main content

taba_cli/tui/theme/
mod.rs

1//! Colors and symbols of the interface (doc 34, TUI-FRM-09): `NO_COLOR` takes the colors away and
2//! `TABA_CLI_ASCII` (or a locale that is not UTF-8) swaps the symbols for ASCII. A state is always a word
3//! **and** a symbol, never only a color.
4
5use ratatui::style::{Color, Modifier, Style};
6
7use crate::tui::api::Severity;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct Theme {
11    pub color: bool,
12    pub ascii: bool,
13}
14
15impl Default for Theme {
16    fn default() -> Theme {
17        Theme {
18            color: true,
19            ascii: false,
20        }
21    }
22}
23
24impl Theme {
25    /// `NO_COLOR` counts when it is not empty (the convention). A locale that does not say UTF-8 means ASCII;
26    /// with no locale at all (Windows) the terminal is assumed to speak UTF-8.
27    pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Theme {
28        let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
29        let utf8 = ["LC_ALL", "LC_CTYPE", "LANG"]
30            .iter()
31            .find_map(|name| env(name).filter(|v| !v.is_empty()))
32            .is_none_or(|locale| {
33                let upper = locale.to_ascii_uppercase();
34                upper.contains("UTF-8") || upper.contains("UTF8")
35            });
36        Theme {
37            color: !set("NO_COLOR"),
38            ascii: set("TABA_CLI_ASCII") || !utf8,
39        }
40    }
41
42    fn paint(self, color: Color) -> Style {
43        if self.color {
44            Style::default().fg(color)
45        } else {
46            Style::default()
47        }
48    }
49
50    pub fn ok(self) -> Style {
51        self.paint(Color::Green)
52    }
53
54    pub fn warning(self) -> Style {
55        self.paint(Color::Yellow)
56    }
57
58    pub fn critical(self) -> Style {
59        self.paint(Color::Red).add_modifier(Modifier::BOLD)
60    }
61
62    pub fn dim(self) -> Style {
63        if self.color {
64            Style::default().fg(Color::DarkGray)
65        } else {
66            Style::default().add_modifier(Modifier::DIM)
67        }
68    }
69
70    pub fn accent(self) -> Style {
71        self.paint(Color::Cyan)
72    }
73
74    pub fn title(self) -> Style {
75        Style::default().add_modifier(Modifier::BOLD)
76    }
77
78    pub fn key(self) -> Style {
79        self.paint(Color::Cyan).add_modifier(Modifier::BOLD)
80    }
81
82    /// The selected row: a bold band over dark gray, not `reversed` (TUI-FRM-09).
83    pub fn selected(self) -> Style {
84        let base = Style::default().add_modifier(Modifier::BOLD);
85        if self.color {
86            base.bg(Color::Indexed(238))
87        } else {
88            base.add_modifier(Modifier::UNDERLINED)
89        }
90    }
91
92    pub fn severity(self, severity: Severity) -> Style {
93        match severity {
94            Severity::Ok => self.ok(),
95            Severity::Warning => self.warning(),
96            Severity::Critical => self.critical(),
97        }
98    }
99
100    /// The symbol before a state word.
101    pub fn icon(self, severity: Severity) -> &'static str {
102        match (severity, self.ascii) {
103            (Severity::Ok, false) => "✔",
104            (Severity::Warning, false) => "▲",
105            (Severity::Critical, false) => "✖",
106            (Severity::Ok, true) => "[ok]",
107            (Severity::Warning, true) => "[!]",
108            (Severity::Critical, true) => "[x]",
109        }
110    }
111
112    pub fn selector(self) -> &'static str {
113        if self.ascii {
114            "> "
115        } else {
116            "▶ "
117        }
118    }
119
120    pub fn offline(self) -> &'static str {
121        if self.ascii {
122            "[x]"
123        } else {
124            "✖"
125        }
126    }
127
128    pub fn refresh(self) -> &'static str {
129        if self.ascii {
130            "~"
131        } else {
132            "⟳"
133        }
134    }
135
136    /// The glyph of a bar of `ratio` (0 to 1) across `width` cells.
137    pub fn bar(self, ratio: f64, width: usize) -> String {
138        let filled = ((ratio.clamp(0.0, 1.0) * width as f64).round() as usize).min(width);
139        let (on, off) = if self.ascii {
140            ('#', '-')
141        } else {
142            ('█', '░')
143        };
144        std::iter::repeat_n(on, filled)
145            .chain(std::iter::repeat_n(off, width - filled))
146            .collect()
147    }
148
149    /// The marker of each chart line, distinct per line (TUI-MON-02).
150    pub fn marker(self, index: usize) -> &'static str {
151        const FANCY: [&str; 4] = ["●", "◆", "▲", "■"];
152        const PLAIN: [&str; 4] = ["*", "+", "o", "#"];
153        if self.ascii {
154            PLAIN[index % 4]
155        } else {
156            FANCY[index % 4]
157        }
158    }
159
160    /// The color of the line of a node in a chart; the marker tells them apart without color.
161    pub fn series(self, index: usize) -> Style {
162        const COLORS: [Color; 6] = [
163            Color::Cyan,
164            Color::Yellow,
165            Color::Green,
166            Color::Magenta,
167            Color::LightBlue,
168            Color::LightRed,
169        ];
170        self.paint(COLORS[index % COLORS.len()])
171    }
172
173    pub fn arrow_up(self) -> &'static str {
174        if self.ascii {
175            "^"
176        } else {
177            "↑"
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests;