1use anstyle::{AnsiColor, Color, Style};
17
18#[derive(Debug, Clone, Copy)]
21pub struct Palette;
22
23impl Palette {
24 #[must_use]
26 pub fn key() -> Style {
27 Style::new().bold()
28 }
29
30 #[must_use]
32 pub fn label() -> Style {
33 Style::new().dimmed()
34 }
35
36 #[must_use]
38 pub fn ok() -> Style {
39 Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)))
40 }
41
42 #[must_use]
44 pub fn warn() -> Style {
45 Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow)))
46 }
47
48 #[must_use]
50 pub fn bad() -> Style {
51 Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)))
52 }
53
54 #[must_use]
56 pub fn url() -> Style {
57 Style::new()
58 .fg_color(Some(Color::Ansi(AnsiColor::Cyan)))
59 .underline()
60 }
61
62 #[must_use]
64 pub fn heading() -> Style {
65 Style::new().bold().underline()
66 }
67
68 #[must_use]
70 pub fn untrusted() -> Style {
71 Style::new().dimmed()
72 }
73}
74
75#[derive(Debug, Clone, Copy)]
77pub struct Painter {
78 enabled: bool,
79}
80
81impl Painter {
82 #[must_use]
84 pub fn colour() -> Self {
85 Self { enabled: true }
86 }
87
88 #[must_use]
90 pub fn plain() -> Self {
91 Self { enabled: false }
92 }
93
94 #[must_use]
96 pub fn for_stream(is_terminal: bool) -> Self {
97 Self {
98 enabled: is_terminal,
99 }
100 }
101
102 #[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 #[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 #[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 #[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 #[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}