scope/display/theme/mod.rs
1//! # Display theme
2//!
3//! The one source of every color and text style that scope shows in a
4//! terminal: CLI output, the TUI and the monitor. Callers name a semantic
5//! [`Role`]; [`tone`] is the single mapping from a role to its look.
6//!
7//! This module is presentation policy. It holds no framework type: the
8//! adapters translate a [`Tone`] to their medium (the text painter in
9//! `text` for CLI output; ratatui styles in the CLI crate).
10//!
11//! Only the 16 named ANSI colors are used, so the terminal's own palette
12//! decides the exact shade on dark and light themes.
13
14mod decision;
15mod text;
16
17pub use decision::{color_enabled, color_enabled_stderr, color_enabled_with, set_color_override};
18pub use text::{Painter, paint};
19
20/// The monitor's color schemes. The configuration type is the scheme, so
21/// one enum names the schemes everywhere.
22pub use crate::config::ColorScheme as Scheme;
23
24/// What a piece of text means. Callers pick a role; never a color.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Role {
27 /// Structure and titles: box rules, section and table headers.
28 Accent,
29 /// Key names in hints and help.
30 Key,
31 /// A passed check, a gain, a healthy state.
32 Ok,
33 /// A warning: outside its target, but not a failure.
34 Warn,
35 /// A failure, a loss, an unhealthy state.
36 Err,
37 /// The glyph of an informational note.
38 Info,
39 /// Labels, descriptions and notes that sit behind the main text.
40 Secondary,
41 /// Plain text in the terminal's default style.
42 Neutral,
43 /// The selected row of a list.
44 Selected,
45 /// A search match.
46 Match,
47 /// A URL.
48 Link,
49}
50
51impl Role {
52 /// Every role, for adapters and tests that walk the whole theme.
53 pub const ALL: [Role; 11] = [
54 Role::Accent,
55 Role::Key,
56 Role::Ok,
57 Role::Warn,
58 Role::Err,
59 Role::Info,
60 Role::Secondary,
61 Role::Neutral,
62 Role::Selected,
63 Role::Match,
64 Role::Link,
65 ];
66}
67
68/// The 16 named ANSI foreground colors.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum AnsiColor {
71 Black,
72 Red,
73 Green,
74 Yellow,
75 Blue,
76 Magenta,
77 Cyan,
78 White,
79 BrightBlack,
80 BrightRed,
81 BrightGreen,
82 BrightYellow,
83 BrightBlue,
84 BrightMagenta,
85 BrightCyan,
86 BrightWhite,
87}
88
89/// How a role looks: a foreground color (`None` keeps the terminal's
90/// default) and text effects.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct Tone {
93 /// The foreground color; `None` keeps the terminal default.
94 pub color: Option<AnsiColor>,
95 /// Bold (heavier) text.
96 pub bold: bool,
97 /// Dim (fainter) text.
98 pub dim: bool,
99 /// Underlined text.
100 pub underline: bool,
101 /// Foreground and background swapped.
102 pub reverse: bool,
103}
104
105impl Tone {
106 /// The terminal's default style: no color, no effect.
107 pub const PLAIN: Tone = Tone {
108 color: None,
109 bold: false,
110 dim: false,
111 underline: false,
112 reverse: false,
113 };
114
115 /// A tone with only a foreground color.
116 const fn colored(color: AnsiColor) -> Tone {
117 Tone {
118 color: Some(color),
119 ..Tone::PLAIN
120 }
121 }
122
123 /// The same tone in bold: the strong form of a role, for verdicts
124 /// (`HEALTHY`, `Critical`) and headings.
125 pub const fn strong(self) -> Tone {
126 Tone { bold: true, ..self }
127 }
128}
129
130/// The single mapping from a role to its tone. The role table in
131/// `docs/design/2026-10-13-tui-color-theme.md` is its specification.
132pub const fn tone(role: Role) -> Tone {
133 match role {
134 Role::Accent => Tone::colored(AnsiColor::Cyan),
135 Role::Key => Tone::colored(AnsiColor::Magenta).strong(),
136 Role::Ok => Tone::colored(AnsiColor::Green),
137 Role::Warn => Tone::colored(AnsiColor::Yellow),
138 Role::Err => Tone::colored(AnsiColor::Red),
139 Role::Info => Tone::colored(AnsiColor::Blue),
140 Role::Secondary => Tone {
141 dim: true,
142 ..Tone::PLAIN
143 },
144 Role::Neutral => Tone::PLAIN,
145 Role::Selected => Tone {
146 reverse: true,
147 ..Tone::colored(AnsiColor::Cyan)
148 },
149 Role::Match => Tone {
150 reverse: true,
151 ..Tone::colored(AnsiColor::Yellow)
152 },
153 Role::Link => Tone {
154 underline: true,
155 ..Tone::PLAIN
156 },
157 }
158}
159
160/// The tones of the series of one chart, one per venue, in venue order
161/// (design 2026-11-04, section 2.9). None is a health color (green,
162/// yellow, red), and none is a bright variant, which is weak on a light
163/// background. From the fifth series the tones repeat; the chart tells
164/// those apart by their marker.
165pub const SERIES_TONES: [Tone; 4] = [
166 Tone::colored(AnsiColor::Blue),
167 Tone::colored(AnsiColor::Magenta),
168 Tone::colored(AnsiColor::Cyan),
169 Tone::PLAIN,
170];
171
172/// Which way a monitored value moved.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum Direction {
175 Up,
176 Down,
177 Flat,
178}
179
180/// The tone of a value that moved in `direction` under a monitor scheme.
181/// A flat value is always plain; the monochrome scheme is always plain.
182pub const fn scheme_tone(scheme: Scheme, direction: Direction) -> Tone {
183 match (scheme, direction) {
184 (_, Direction::Flat) | (Scheme::Monochrome, _) => Tone::PLAIN,
185 (Scheme::GreenRed, Direction::Up) => Tone::colored(AnsiColor::Green),
186 (Scheme::GreenRed, Direction::Down) => Tone::colored(AnsiColor::Red),
187 (Scheme::BlueOrange, Direction::Up) => Tone::colored(AnsiColor::Blue),
188 // Orange is not one of the 16 named colors; yellow is the nearest.
189 (Scheme::BlueOrange, Direction::Down) => Tone::colored(AnsiColor::Yellow),
190 }
191}
192
193/// The tone of `role` in a monitor under `scheme`. The monochrome scheme
194/// shows no color, so it keeps only the role's effects (bold, dim, ...).
195pub const fn scheme_role_tone(scheme: Scheme, role: Role) -> Tone {
196 let t = tone(role);
197 match scheme {
198 Scheme::Monochrome => Tone { color: None, ..t },
199 Scheme::GreenRed | Scheme::BlueOrange => t,
200 }
201}
202
203#[cfg(test)]
204mod tests;