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    /// Pad to `width` **before** styling.
112    ///
113    /// Escape codes have no width but plenty of bytes, so padding a styled
114    /// string with `{:<12}` misaligns every column after it.
115    #[must_use]
116    pub fn paint_padded(self, text: &str, width: usize, style: Style) -> String {
117        let visible = text.chars().count();
118        let padding = width.saturating_sub(visible);
119        format!("{}{}", self.paint(text, style), " ".repeat(padding))
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn a_plain_painter_changes_nothing() {
129        assert_eq!(Painter::plain().paint("PROJ-1", Palette::key()), "PROJ-1");
130    }
131
132    #[test]
133    fn a_colour_painter_wraps_and_resets() {
134        let painted = Painter::colour().paint("PROJ-1", Palette::key());
135        assert!(painted.starts_with('\u{1b}'));
136        assert!(painted.contains("PROJ-1"));
137        assert!(painted.ends_with("\u{1b}[0m"));
138    }
139
140    /// Columns must line up whether or not anything is painted: escape codes
141    /// carry bytes but no width.
142    #[test]
143    fn padding_counts_visible_characters_only() {
144        let plain = Painter::plain().paint_padded("PROJ-1", 12, Palette::key());
145        let coloured = Painter::colour().paint_padded("PROJ-1", 12, Palette::key());
146
147        assert_eq!(plain, "PROJ-1      ");
148        assert!(coloured.ends_with("      "));
149        assert_eq!(
150            coloured.matches(' ').count(),
151            plain.matches(' ').count(),
152            "same visible width in both modes"
153        );
154    }
155
156    #[test]
157    fn text_longer_than_the_column_is_not_truncated_by_padding() {
158        assert_eq!(
159            Painter::plain().paint_padded("very-long-key", 4, Palette::key()),
160            "very-long-key"
161        );
162    }
163}