Skip to main content

scope/display/theme/
text.rs

1//! The text adapter of the theme: it renders a [`Tone`] as ANSI SGR escape
2//! codes around a string, for CLI output.
3//!
4//! Each part of a tone wraps the text on its own, the color outermost and
5//! the effects inside it. A color ends with "default foreground"; an
6//! effect ends with "reset all". This keeps the bytes that the `colored`
7//! style crates wrote before the theme existed.
8
9use super::decision::{color_enabled, color_enabled_stderr};
10use super::{AnsiColor, Role, Tone, tone};
11
12const CSI: &str = "\x1b[";
13const SGR_END: &str = "m";
14const RESET_ALL: u8 = 0;
15const BOLD: u8 = 1;
16const DIM: u8 = 2;
17const UNDERLINE: u8 = 4;
18const REVERSE: u8 = 7;
19const DEFAULT_FOREGROUND: u8 = 39;
20
21/// Paints text for one output stream: with escape codes when color is on
22/// for it, as the text unchanged when color is off.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Painter {
25    enabled: bool,
26}
27
28impl Painter {
29    /// A painter that paints when `enabled` is true. Use it when the
30    /// caller already holds the color decision.
31    pub const fn new(enabled: bool) -> Self {
32        Self { enabled }
33    }
34
35    /// A painter for stdout, following [`color_enabled`].
36    pub fn stdout() -> Self {
37        Self::new(color_enabled())
38    }
39
40    /// A painter for stderr, following [`color_enabled_stderr`].
41    pub fn stderr() -> Self {
42        Self::new(color_enabled_stderr())
43    }
44
45    /// `text` in the tone of `role`.
46    pub fn paint(self, role: Role, text: &str) -> String {
47        self.paint_tone(tone(role), text)
48    }
49
50    /// `text` in the strong (bold) form of `role`.
51    pub fn paint_strong(self, role: Role, text: &str) -> String {
52        self.paint_tone(tone(role).strong(), text)
53    }
54
55    /// `text` in `tone`, for tones that do not come from a role (the
56    /// monitor schemes).
57    pub fn paint_tone(self, tone: Tone, text: &str) -> String {
58        if self.enabled {
59            render(tone, text)
60        } else {
61            text.to_string()
62        }
63    }
64}
65
66/// `text` in the tone of `role`, for stdout.
67pub fn paint(role: Role, text: &str) -> String {
68    Painter::stdout().paint(role, text)
69}
70
71/// Wraps `text` in the escape codes of `tone`.
72fn render(tone: Tone, text: &str) -> String {
73    let effects = [
74        (tone.reverse, REVERSE),
75        (tone.underline, UNDERLINE),
76        (tone.dim, DIM),
77        (tone.bold, BOLD),
78    ];
79    let mut out = text.to_string();
80    for (_, code) in effects.into_iter().filter(|(on, _)| *on) {
81        out = wrap(code, &out, RESET_ALL);
82    }
83    match tone.color {
84        Some(color) => wrap(foreground_code(color), &out, DEFAULT_FOREGROUND),
85        None => out,
86    }
87}
88
89fn wrap(start: u8, text: &str, end: u8) -> String {
90    format!("{CSI}{start}{SGR_END}{text}{CSI}{end}{SGR_END}")
91}
92
93/// The SGR foreground code of a named color.
94const fn foreground_code(color: AnsiColor) -> u8 {
95    match color {
96        AnsiColor::Black => 30,
97        AnsiColor::Red => 31,
98        AnsiColor::Green => 32,
99        AnsiColor::Yellow => 33,
100        AnsiColor::Blue => 34,
101        AnsiColor::Magenta => 35,
102        AnsiColor::Cyan => 36,
103        AnsiColor::White => 37,
104        AnsiColor::BrightBlack => 90,
105        AnsiColor::BrightRed => 91,
106        AnsiColor::BrightGreen => 92,
107        AnsiColor::BrightYellow => 93,
108        AnsiColor::BrightBlue => 94,
109        AnsiColor::BrightMagenta => 95,
110        AnsiColor::BrightCyan => 96,
111        AnsiColor::BrightWhite => 97,
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    const ON: Painter = Painter::new(true);
120    const OFF: Painter = Painter::new(false);
121
122    #[test]
123    fn color_off_leaves_every_role_as_plain_text() {
124        // The plain output (NO_COLOR, pipes) must never carry an escape.
125        for role in Role::ALL {
126            assert_eq!(OFF.paint(role, "x"), "x", "{role:?}");
127            assert_eq!(OFF.paint_strong(role, "x"), "x", "{role:?}");
128        }
129    }
130
131    #[test]
132    fn color_on_marks_every_role_but_neutral() {
133        for role in Role::ALL {
134            let painted = ON.paint(role, "x");
135            assert_eq!(painted == "x", role == Role::Neutral, "{role:?}");
136        }
137    }
138
139    #[test]
140    fn a_color_closes_with_default_foreground_and_effects_reset_inside_it() {
141        assert_eq!(ON.paint(Role::Accent, "x"), "\x1b[36mx\x1b[39m");
142        assert_eq!(ON.paint(Role::Secondary, "x"), "\x1b[2mx\x1b[0m");
143        assert_eq!(
144            ON.paint_strong(Role::Err, "x"),
145            "\x1b[31m\x1b[1mx\x1b[0m\x1b[39m"
146        );
147        assert_eq!(
148            ON.paint(Role::Selected, "x"),
149            "\x1b[36m\x1b[7mx\x1b[0m\x1b[39m"
150        );
151    }
152
153    #[test]
154    fn bright_colors_use_the_high_intensity_codes() {
155        let tone = Tone {
156            color: Some(AnsiColor::BrightBlack),
157            ..Tone::PLAIN
158        };
159        assert_eq!(ON.paint_tone(tone, "x"), "\x1b[90mx\x1b[39m");
160    }
161}