Skip to main content

ytcli/render/
style.rs

1//! Colour and emphasis.
2//!
3//! Three rules hold here, and they are the reason this is a module rather than
4//! scattered `\x1b[` literals:
5//!
6//! 1. **Styling never changes the data.** The same fields, in the same order,
7//!    with the same words, whether or not anything is painted. Only the escape
8//!    codes differ, so a pipe and a terminal disagree about nothing that matters.
9//! 2. **Machine output is never styled.** Not stripped afterwards — never
10//!    produced. Snapshot tests then pin the real bytes a caller receives.
11//! 3. **Untrusted text is never given our chrome.** Descriptions and comments
12//!    are dimmed and nothing more. Painting them like tool output would let an
13//!    issue's text impersonate the tool talking, which is exactly the confusion
14//!    the fence exists to prevent (ADR 1).
15
16use anstyle::{AnsiColor, Color, Style};
17
18/// The palette. Small on purpose: a listing that uses six colours communicates
19/// less than one that uses two.
20#[derive(Debug, Clone, Copy)]
21pub struct Palette;
22
23impl Palette {
24    /// Identifiers a caller will type back: issue keys, queue keys, profile names.
25    #[must_use]
26    pub fn key() -> Style {
27        Style::new().bold()
28    }
29
30    /// Field labels and other scaffolding.
31    #[must_use]
32    pub fn label() -> Style {
33        Style::new().dimmed()
34    }
35
36    /// Something worked.
37    #[must_use]
38    pub fn ok() -> Style {
39        Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)))
40    }
41
42    /// Something needs attention but is not broken.
43    #[must_use]
44    pub fn warn() -> Style {
45        Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow)))
46    }
47
48    /// Something is broken.
49    #[must_use]
50    pub fn bad() -> Style {
51        Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)))
52    }
53
54    /// A link worth following.
55    #[must_use]
56    pub fn url() -> Style {
57        Style::new()
58            .fg_color(Some(Color::Ansi(AnsiColor::Cyan)))
59            .underline()
60    }
61
62    /// A heading in a block of prose.
63    #[must_use]
64    pub fn heading() -> Style {
65        Style::new().bold().underline()
66    }
67
68    /// Text somebody else wrote.
69    #[must_use]
70    pub fn untrusted() -> Style {
71        Style::new().dimmed()
72    }
73}
74
75/// Applies the palette, or does not.
76#[derive(Debug, Clone, Copy)]
77pub struct Painter {
78    enabled: bool,
79}
80
81impl Painter {
82    /// A painter that styles.
83    #[must_use]
84    pub fn colour() -> Self {
85        Self { enabled: true }
86    }
87
88    /// A painter that leaves text exactly as it is.
89    #[must_use]
90    pub fn plain() -> Self {
91        Self { enabled: false }
92    }
93
94    /// Style for a terminal, plain for anything else.
95    #[must_use]
96    pub fn for_stream(is_terminal: bool) -> Self {
97        Self {
98            enabled: is_terminal,
99        }
100    }
101
102    /// Wrap `text` in `style`, or return it unchanged.
103    #[must_use]
104    pub fn paint(self, text: &str, style: Style) -> String {
105        if !self.enabled {
106            return text.to_owned();
107        }
108        format!("{style}{text}{style:#}")
109    }
110
111    /// A URL a terminal can open with a click.
112    ///
113    /// Terminals guess at bare URLs, and guess wrong once anything is painted
114    /// next to them; an OSC 8 hyperlink says where the link is. The visible text
115    /// is the URL itself either way, so nothing is hidden behind a label.
116    #[must_use]
117    pub fn link(self, url: &str) -> String {
118        if !self.enabled {
119            return url.to_owned();
120        }
121        format!(
122            "\u{1b}]8;;{url}\u{1b}\\{}\u{1b}]8;;\u{1b}\\",
123            self.paint(url, Palette::url())
124        )
125    }
126
127    /// Pad to `width` **before** styling.
128    ///
129    /// Escape codes have no width but plenty of bytes, so padding a styled
130    /// string with `{:<12}` misaligns every column after it.
131    #[must_use]
132    pub fn paint_padded(self, text: &str, width: usize, style: Style) -> String {
133        let visible = text.chars().count();
134        let padding = width.saturating_sub(visible);
135        format!("{}{}", self.paint(text, style), " ".repeat(padding))
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn a_plain_painter_changes_nothing() {
145        assert_eq!(Painter::plain().paint("PROJ-1", Palette::key()), "PROJ-1");
146    }
147
148    #[test]
149    fn a_colour_painter_wraps_and_resets() {
150        let painted = Painter::colour().paint("PROJ-1", Palette::key());
151        assert!(painted.starts_with('\u{1b}'));
152        assert!(painted.contains("PROJ-1"));
153        assert!(painted.ends_with("\u{1b}[0m"));
154    }
155
156    /// Columns must line up whether or not anything is painted: escape codes
157    /// carry bytes but no width.
158    #[test]
159    fn padding_counts_visible_characters_only() {
160        let plain = Painter::plain().paint_padded("PROJ-1", 12, Palette::key());
161        let coloured = Painter::colour().paint_padded("PROJ-1", 12, Palette::key());
162
163        assert_eq!(plain, "PROJ-1      ");
164        assert!(coloured.ends_with("      "));
165        assert_eq!(
166            coloured.matches(' ').count(),
167            plain.matches(' ').count(),
168            "same visible width in both modes"
169        );
170    }
171
172    /// A link in a pipe is the bare URL, and in a terminal still shows the URL.
173    #[test]
174    fn a_link_is_the_url_whether_or_not_it_is_clickable() {
175        let url = "https://ya.ru/device";
176        assert_eq!(Painter::plain().link(url), url);
177
178        let linked = Painter::colour().link(url);
179        assert!(linked.starts_with("\u{1b}]8;;https://ya.ru/device\u{1b}\\"));
180        assert!(linked.ends_with("\u{1b}]8;;\u{1b}\\"));
181        assert_eq!(linked.matches(url).count(), 2, "target and visible text");
182    }
183
184    #[test]
185    fn text_longer_than_the_column_is_not_truncated_by_padding() {
186        assert_eq!(
187            Painter::plain().paint_padded("very-long-key", 4, Palette::key()),
188            "very-long-key"
189        );
190    }
191}