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, 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 strong (bold) form of a role: verdicts, states and headings.
114pub const fn strong(role: Role) -> Style {
115    tone_style(tone(role).strong())
116}
117
118// =============================================================================
119// Monitor palette
120// =============================================================================
121
122/// Extension trait for `ColorScheme`: its ratatui palette.
123pub trait ColorSchemeExt {
124    fn palette(&self) -> ColorPalette;
125}
126
127impl ColorSchemeExt for Scheme {
128    /// The palette for this scheme, from the theme (`scope::display::theme`):
129    /// up and down per scheme, the rest from their roles. Monochrome keeps
130    /// no color.
131    fn palette(&self) -> ColorPalette {
132        let scheme = *self;
133        let role = |r| tone_color(scheme_role_tone(scheme, r));
134        ColorPalette {
135            up: tone_color(scheme_tone(scheme, Direction::Up)),
136            down: tone_color(scheme_tone(scheme, Direction::Down)),
137            neutral: tone_color(Tone::PLAIN),
138            header_fg: tone_color(Tone::PLAIN),
139            highlight: role(Role::Warn),
140            volume_bar: role(Role::Info),
141            sparkline: role(Role::Accent),
142        }
143    }
144}
145
146/// Named color palette derived from a ColorScheme.
147#[derive(Debug, Clone, Copy)]
148pub struct ColorPalette {
149    /// Color for price-up / bullish.
150    pub up: Color,
151    /// Color for price-down / bearish.
152    pub down: Color,
153    /// Neutral/secondary text color.
154    pub neutral: Color,
155    /// Header foreground.
156    pub header_fg: Color,
157    /// Highlight/accent color.
158    pub highlight: Color,
159    /// Volume bar color.
160    pub volume_bar: Color,
161    /// Sparkline color.
162    pub sparkline: Color,
163}
164
165/// What the output pane is doing. It sets the rule color and the state word.
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub enum PaneState {
168    /// No command runs and the last one did not fail.
169    Idle,
170    /// A command runs.
171    Running,
172    /// The last command failed. Lasts until the next command starts.
173    Failed,
174}
175
176impl PaneState {
177    /// The word at the right end of the top rule.
178    pub fn word(self) -> &'static str {
179        match self {
180            Self::Idle => "idle",
181            Self::Running => "running",
182            Self::Failed => "failed",
183        }
184    }
185
186    /// The color of the pane rule and title.
187    pub fn style(self) -> Style {
188        match self {
189            Self::Idle => ACCENT,
190            Self::Running => WARN,
191            Self::Failed => ERR,
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_secondary_is_dim_on_the_default_foreground() {
202        assert_eq!(SECONDARY.fg, None);
203        assert!(SECONDARY.add_modifier.contains(Modifier::DIM));
204    }
205
206    #[test]
207    fn test_only_named_ansi_colors_are_used() {
208        // Named colors let the terminal palette pick the shade.
209        for style in [ACCENT, KEY, OK, WARN, ERR, INFO, SELECTED, MATCH] {
210            assert!(matches!(
211                style.fg,
212                Some(
213                    Color::Cyan
214                        | Color::Magenta
215                        | Color::Green
216                        | Color::Yellow
217                        | Color::Red
218                        | Color::Blue
219                )
220            ));
221        }
222    }
223
224    #[test]
225    fn test_pane_state_maps_to_a_rule_color_and_a_word() {
226        assert_eq!(PaneState::Idle.style(), ACCENT);
227        assert_eq!(PaneState::Running.style(), WARN);
228        assert_eq!(PaneState::Failed.style(), ERR);
229        assert_eq!(PaneState::Idle.word(), "idle");
230        assert_eq!(PaneState::Running.word(), "running");
231        assert_eq!(PaneState::Failed.word(), "failed");
232    }
233
234    #[test]
235    fn test_selected_and_match_are_reversed_cyan_and_yellow() {
236        assert_eq!(SELECTED.fg, Some(Color::Cyan));
237        assert!(SELECTED.add_modifier.contains(Modifier::REVERSED));
238        assert_eq!(MATCH.fg, Some(Color::Yellow));
239        assert!(MATCH.add_modifier.contains(Modifier::REVERSED));
240    }
241
242    /// The style of text painted with `role`, after the pane parses its
243    /// ANSI, as `TuiState::push_ansi` does.
244    fn parsed(role: Role) -> Style {
245        use ansi_to_tui::IntoText;
246        let painted = scope::display::theme::Painter::new(true).paint(role, "x");
247        let text = painted.into_text().unwrap();
248        text.lines[0].spans[0].style
249    }
250
251    #[test]
252    fn test_painted_text_parses_back_to_the_same_style() {
253        // Help text reaches the pane as ANSI from the text painter. Both
254        // adapters must give a role the same look in the pane.
255        for role in [
256            Role::Key,
257            Role::Secondary,
258            Role::Accent,
259            Role::Ok,
260            Role::Err,
261        ] {
262            assert_eq!(parsed(role), style(role), "{role:?}");
263        }
264    }
265
266    #[test]
267    fn test_scheme_styles_follow_the_core_scheme_tones() {
268        assert_eq!(
269            scheme_style(Scheme::GreenRed, Direction::Up).fg,
270            Some(Color::Green)
271        );
272        assert_eq!(
273            scheme_style(Scheme::BlueOrange, Direction::Down).fg,
274            Some(Color::Yellow)
275        );
276        assert_eq!(
277            scheme_style(Scheme::Monochrome, Direction::Up),
278            Style::new()
279        );
280    }
281
282    #[test]
283    fn test_key_is_bold_magenta() {
284        assert_eq!(KEY.fg, Some(Color::Magenta));
285        assert!(KEY.add_modifier.contains(Modifier::BOLD));
286    }
287}