Skip to main content

scope_cli/cli/
theme.rs

1//! The ratatui adapter of the theme (`scope::display::theme`).
2//!
3//! It only translates: each role's tone becomes a ratatui `Style`, and this
4//! file is the one TUI file that names a ratatui color. Only the 16 named
5//! ANSI colors are used, so the terminal's own palette decides the exact
6//! shade on dark and light themes. No code here checks whether color is
7//! on: `plain::finish_frame` strips every fg and bg color from a frame when
8//! it is off. Modifiers (bold, dim, reverse) stay, and each state also shows
9//! a word or glyph, so no meaning depends on color. The contrast guard
10//! (`tests/tui_contrast.rs`) keeps other files from naming a color.
11
12use ratatui::style::{Color, Modifier, Style};
13use scope::display::theme::{
14    AnsiColor, Direction, Role, SERIES_TONES, Scheme, Tone, scheme_role_tone, scheme_tone, tone,
15};
16
17/// The ratatui color of a named ANSI color.
18const fn color(c: AnsiColor) -> Color {
19    match c {
20        AnsiColor::Black => Color::Black,
21        AnsiColor::Red => Color::Red,
22        AnsiColor::Green => Color::Green,
23        AnsiColor::Yellow => Color::Yellow,
24        AnsiColor::Blue => Color::Blue,
25        AnsiColor::Magenta => Color::Magenta,
26        AnsiColor::Cyan => Color::Cyan,
27        AnsiColor::White => Color::Gray,
28        AnsiColor::BrightBlack => Color::DarkGray,
29        AnsiColor::BrightRed => Color::LightRed,
30        AnsiColor::BrightGreen => Color::LightGreen,
31        AnsiColor::BrightYellow => Color::LightYellow,
32        AnsiColor::BrightBlue => Color::LightBlue,
33        AnsiColor::BrightMagenta => Color::LightMagenta,
34        AnsiColor::BrightCyan => Color::LightCyan,
35        AnsiColor::BrightWhite => Color::White,
36    }
37}
38
39/// The ratatui foreground color of a tone: its color, or the terminal
40/// default (`Reset`) when it has none. Effects are not part of a color.
41pub const fn tone_color(t: Tone) -> Color {
42    match t.color {
43        Some(c) => color(c),
44        None => Color::Reset,
45    }
46}
47
48/// The terminal's own default color: what a cell shows with color off.
49pub const DEFAULT_COLOR: Color = tone_color(Tone::PLAIN);
50
51/// The ratatui style of a tone.
52pub const fn tone_style(t: Tone) -> Style {
53    let mut s = Style::new();
54    if let Some(c) = t.color {
55        s = s.fg(color(c));
56    }
57    if t.bold {
58        s = s.add_modifier(Modifier::BOLD);
59    }
60    if t.dim {
61        s = s.add_modifier(Modifier::DIM);
62    }
63    if t.underline {
64        s = s.add_modifier(Modifier::UNDERLINED);
65    }
66    if t.reverse {
67        s = s.add_modifier(Modifier::REVERSED);
68    }
69    s
70}
71
72/// The ratatui style of a role.
73pub const fn style(role: Role) -> Style {
74    tone_style(tone(role))
75}
76
77/// The ratatui style of a value that moved in `direction` under `scheme`.
78pub const fn scheme_style(scheme: Scheme, direction: Direction) -> Style {
79    tone_style(scheme_tone(scheme, direction))
80}
81
82/// Title, idle pane rule, prompt `scope`, status values, palette names.
83pub const ACCENT: Style = style(Role::Accent);
84
85/// Key names in hints and help, and the prompt `:<chain>`.
86pub const KEY: Style = style(Role::Key);
87
88/// `✓ Done in N.Ns.`
89pub const OK: Style = style(Role::Ok);
90
91/// Running pane rule and title, `running Ns`, spinner, `Cancelled after`.
92pub const WARN: Style = style(Role::Warn);
93
94/// Failed pane rule and `✗ Failed in N.Ns.` (the footer adds bold).
95pub const ERR: Style = style(Role::Err);
96
97/// The `ℹ` glyph on notes.
98pub const INFO: Style = style(Role::Info);
99
100/// Secondary text: the default foreground, dimmed. DIM is a modifier, so it
101/// also holds under `NO_COLOR`.
102pub const SECONDARY: Style = style(Role::Secondary);
103
104/// The selected palette or menu row.
105pub const SELECTED: Style = style(Role::Selected);
106
107/// A search match.
108pub const MATCH: Style = style(Role::Match);
109
110/// A URL in a link row.
111pub const LINK: Style = style(Role::Link);
112
113/// The style of the `i`-th series of a chart (a venue's line). The tones
114/// repeat from the fifth series.
115pub const fn series_style(i: usize) -> Style {
116    tone_style(SERIES_TONES[i % SERIES_TONES.len()])
117}
118
119/// The strong (bold) form of a role: verdicts, states and headings.
120pub const fn strong(role: Role) -> Style {
121    tone_style(tone(role).strong())
122}
123
124// =============================================================================
125// Monitor palette
126// =============================================================================
127
128/// Extension trait for `ColorScheme`: its ratatui palette.
129pub trait ColorSchemeExt {
130    fn palette(&self) -> ColorPalette;
131}
132
133impl ColorSchemeExt for Scheme {
134    /// The palette for this scheme, from the theme (`scope::display::theme`):
135    /// up and down per scheme, the rest from their roles. Monochrome keeps
136    /// no color.
137    fn palette(&self) -> ColorPalette {
138        let scheme = *self;
139        let role = |r| tone_color(scheme_role_tone(scheme, r));
140        ColorPalette {
141            up: tone_color(scheme_tone(scheme, Direction::Up)),
142            down: tone_color(scheme_tone(scheme, Direction::Down)),
143            neutral: tone_color(Tone::PLAIN),
144            header_fg: tone_color(Tone::PLAIN),
145            highlight: role(Role::Warn),
146            volume_bar: role(Role::Info),
147            sparkline: role(Role::Accent),
148        }
149    }
150}
151
152/// Named color palette derived from a ColorScheme.
153#[derive(Debug, Clone, Copy)]
154pub struct ColorPalette {
155    /// Color for price-up / bullish.
156    pub up: Color,
157    /// Color for price-down / bearish.
158    pub down: Color,
159    /// Neutral/secondary text color.
160    pub neutral: Color,
161    /// Header foreground.
162    pub header_fg: Color,
163    /// Highlight/accent color.
164    pub highlight: Color,
165    /// Volume bar color.
166    pub volume_bar: Color,
167    /// Sparkline color.
168    pub sparkline: Color,
169}
170
171/// What the output pane is doing. It sets the rule color and the state word.
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum PaneState {
174    /// No command runs and the last one did not fail.
175    Idle,
176    /// A command runs.
177    Running,
178    /// The last command failed. Lasts until the next command starts.
179    Failed,
180}
181
182impl PaneState {
183    /// The word at the right end of the top rule.
184    pub fn word(self) -> &'static str {
185        match self {
186            Self::Idle => "idle",
187            Self::Running => "running",
188            Self::Failed => "failed",
189        }
190    }
191
192    /// The color of the pane rule and title.
193    pub fn style(self) -> Style {
194        match self {
195            Self::Idle => ACCENT,
196            Self::Running => WARN,
197            Self::Failed => ERR,
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_secondary_is_dim_on_the_default_foreground() {
208        assert_eq!(SECONDARY.fg, None);
209        assert!(SECONDARY.add_modifier.contains(Modifier::DIM));
210    }
211
212    #[test]
213    fn test_only_named_ansi_colors_are_used() {
214        // Named colors let the terminal palette pick the shade.
215        for style in [ACCENT, KEY, OK, WARN, ERR, INFO, SELECTED, MATCH] {
216            assert!(matches!(
217                style.fg,
218                Some(
219                    Color::Cyan
220                        | Color::Magenta
221                        | Color::Green
222                        | Color::Yellow
223                        | Color::Red
224                        | Color::Blue
225                )
226            ));
227        }
228    }
229
230    #[test]
231    fn test_pane_state_maps_to_a_rule_color_and_a_word() {
232        assert_eq!(PaneState::Idle.style(), ACCENT);
233        assert_eq!(PaneState::Running.style(), WARN);
234        assert_eq!(PaneState::Failed.style(), ERR);
235        assert_eq!(PaneState::Idle.word(), "idle");
236        assert_eq!(PaneState::Running.word(), "running");
237        assert_eq!(PaneState::Failed.word(), "failed");
238    }
239
240    #[test]
241    fn test_selected_and_match_are_reversed_cyan_and_yellow() {
242        assert_eq!(SELECTED.fg, Some(Color::Cyan));
243        assert!(SELECTED.add_modifier.contains(Modifier::REVERSED));
244        assert_eq!(MATCH.fg, Some(Color::Yellow));
245        assert!(MATCH.add_modifier.contains(Modifier::REVERSED));
246    }
247
248    /// The style of text painted with `role`, after the pane parses its
249    /// ANSI, as `TuiState::push_ansi` does.
250    fn parsed(role: Role) -> Style {
251        use ansi_to_tui::IntoText;
252        let painted = scope::display::theme::Painter::new(true).paint(role, "x");
253        let text = painted.into_text().unwrap();
254        text.lines[0].spans[0].style
255    }
256
257    #[test]
258    fn test_painted_text_parses_back_to_the_same_style() {
259        // Help text reaches the pane as ANSI from the text painter. Both
260        // adapters must give a role the same look in the pane.
261        for role in [
262            Role::Key,
263            Role::Secondary,
264            Role::Accent,
265            Role::Ok,
266            Role::Err,
267        ] {
268            assert_eq!(parsed(role), style(role), "{role:?}");
269        }
270    }
271
272    #[test]
273    fn test_scheme_styles_follow_the_core_scheme_tones() {
274        assert_eq!(
275            scheme_style(Scheme::GreenRed, Direction::Up).fg,
276            Some(Color::Green)
277        );
278        assert_eq!(
279            scheme_style(Scheme::BlueOrange, Direction::Down).fg,
280            Some(Color::Yellow)
281        );
282        assert_eq!(
283            scheme_style(Scheme::Monochrome, Direction::Up),
284            Style::new()
285        );
286    }
287
288    // A venue's line must never look like a health state, and must read
289    // on dark and light themes.
290    #[test]
291    fn test_series_styles_are_no_health_color_and_repeat_after_four() {
292        let health = [OK.fg, WARN.fg, ERR.fg];
293        for i in 0..SERIES_TONES.len() {
294            let fg = series_style(i).fg;
295            assert!(!health.contains(&fg), "series {i}: {fg:?}");
296            assert!(
297                matches!(fg, None | Some(Color::Blue | Color::Magenta | Color::Cyan)),
298                "series {i}: {fg:?}"
299            );
300        }
301        assert_eq!(series_style(4), series_style(0));
302        let distinct: std::collections::HashSet<_> = (0..4).map(|i| series_style(i).fg).collect();
303        assert_eq!(distinct.len(), 4);
304    }
305
306    #[test]
307    fn test_key_is_bold_magenta() {
308        assert_eq!(KEY.fg, Some(Color::Magenta));
309        assert!(KEY.add_modifier.contains(Modifier::BOLD));
310    }
311}