cli_forge/style.rs
1//! The styling builder and the rendering core shared by every styling path.
2//!
3//! [`Style`] is the function-call path: build a color/attribute set by chaining
4//! methods, then drop it straight into [`out`](crate::out) because it implements
5//! [`Display`]. The same attribute set is what the tag parser and the named
6//! registry produce, and all three render through one private function,
7//! [`write_styled`], so identical intent yields byte-identical output.
8
9use std::fmt::{self, Display, Write};
10
11use crate::color::Color;
12use crate::terminal::{self, ColorLevel};
13
14/// The visual attributes of a styled run: a foreground color plus the bold and
15/// underline flags. Cheap to copy, so it threads through the tag parser and the
16/// registry without allocation.
17#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
18pub(crate) struct StyleAttrs {
19 pub(crate) fg: Option<Color>,
20 pub(crate) bold: bool,
21 pub(crate) underline: bool,
22}
23
24impl StyleAttrs {
25 /// No color and no attributes.
26 pub(crate) const EMPTY: StyleAttrs = StyleAttrs {
27 fg: None,
28 bold: false,
29 underline: false,
30 };
31
32 /// Whether this set would emit no escape sequences.
33 #[inline]
34 pub(crate) fn is_empty(self) -> bool {
35 self.fg.is_none() && !self.bold && !self.underline
36 }
37}
38
39/// Render `text` with `attrs` at `level`, writing to `w`.
40///
41/// This is the single rendering primitive behind the builder, the tag parser,
42/// and the registry. Parameters are emitted in a fixed canonical order — bold
43/// (`1`), underline (`4`), then the foreground color — so two callers expressing
44/// the same intent produce the same bytes regardless of the order they set
45/// things. At [`ColorLevel::None`], or when `attrs` is empty, the plain text is
46/// written with no escape sequences.
47pub(crate) fn write_styled<W: Write>(
48 w: &mut W,
49 attrs: StyleAttrs,
50 text: &str,
51 level: ColorLevel,
52) -> fmt::Result {
53 if level.is_none() || attrs.is_empty() {
54 return w.write_str(text);
55 }
56
57 w.write_str("\x1b[")?;
58 let mut first = true;
59 if attrs.bold {
60 w.write_str("1")?;
61 first = false;
62 }
63 if attrs.underline {
64 if !first {
65 w.write_char(';')?;
66 }
67 w.write_str("4")?;
68 first = false;
69 }
70 if let Some(color) = attrs.fg {
71 color.write_fg(w, level, &mut first)?;
72 }
73 w.write_str("m")?;
74 w.write_str(text)?;
75 w.write_str("\x1b[0m")
76}
77
78/// A piece of text together with the color and attributes to render it with.
79///
80/// Created by [`style`]. The setter methods consume and return `self`, so they
81/// chain. `Style` implements [`Display`]: passing one to [`out`](crate::out)
82/// renders it, and the color depth matches whatever the terminal supports.
83///
84/// # Examples
85///
86/// ```
87/// use cli_forge::{out, style};
88///
89/// out(style("done").green().bold());
90/// out(style("note").hex("#88aaff"));
91/// out(style("ok").rgb(0, 200, 120));
92/// ```
93#[derive(Clone, Debug)]
94pub struct Style {
95 text: String,
96 attrs: StyleAttrs,
97}
98
99/// Begin styling `text`.
100///
101/// The returned [`Style`] starts plain; chain color and attribute methods onto
102/// it. `text` accepts anything convertible into a `String`, so both string
103/// literals and owned `String`s work.
104///
105/// # Examples
106///
107/// ```
108/// use cli_forge::style;
109///
110/// let warning = style("low disk space").yellow().bold();
111/// // `Style` is `Display`, so it renders when printed or formatted.
112/// assert!(warning.render().contains("low disk space"));
113/// ```
114#[must_use]
115pub fn style<S: Into<String>>(text: S) -> Style {
116 Style {
117 text: text.into(),
118 attrs: StyleAttrs::EMPTY,
119 }
120}
121
122/// Generate a consuming builder method that sets the foreground to a named color.
123macro_rules! named_color_method {
124 ($(#[$meta:meta])* $name:ident => $variant:ident) => {
125 $(#[$meta])*
126 #[must_use]
127 pub fn $name(mut self) -> Style {
128 self.attrs.fg = Some(Color::$variant);
129 self
130 }
131 };
132}
133
134impl Style {
135 named_color_method!(/// Set the foreground to the standard black.
136 black => Black);
137 named_color_method!(/// Set the foreground to the standard red.
138 red => Red);
139 named_color_method!(/// Set the foreground to the standard green.
140 green => Green);
141 named_color_method!(/// Set the foreground to the standard yellow.
142 yellow => Yellow);
143 named_color_method!(/// Set the foreground to the standard blue.
144 blue => Blue);
145 named_color_method!(/// Set the foreground to the standard magenta.
146 magenta => Magenta);
147 named_color_method!(/// Set the foreground to the standard cyan.
148 cyan => Cyan);
149 named_color_method!(/// Set the foreground to the standard white.
150 white => White);
151
152 /// Set the foreground to a 24-bit hex color, e.g. `"#ff8800"`.
153 ///
154 /// The leading `#` is optional; the rest must be exactly six hex digits. An
155 /// invalid string leaves the current color unchanged, so the builder never
156 /// fails. On terminals without 24-bit support the color is downgraded to the
157 /// nearest representable value at render time.
158 ///
159 /// # Examples
160 ///
161 /// ```
162 /// use cli_forge::style;
163 ///
164 /// let link = style("https://example.com").hex("#3b82f6").underline();
165 /// assert!(link.render().contains("https://example.com"));
166 ///
167 /// // An invalid hex string is ignored rather than panicking.
168 /// let plain = style("x").hex("nope");
169 /// assert_eq!(plain.render(), "x");
170 /// ```
171 #[must_use]
172 pub fn hex(mut self, hex: &str) -> Style {
173 if let Some(color) = Color::from_hex(hex) {
174 self.attrs.fg = Some(color);
175 }
176 self
177 }
178
179 /// Set the foreground to a 24-bit RGB color.
180 ///
181 /// On terminals without 24-bit support the color is downgraded to the
182 /// nearest representable value at render time.
183 ///
184 /// # Examples
185 ///
186 /// ```
187 /// use cli_forge::style;
188 ///
189 /// let teal = style("ok").rgb(0, 200, 120);
190 /// assert!(teal.render().contains("ok"));
191 /// ```
192 #[must_use]
193 pub fn rgb(mut self, r: u8, g: u8, b: u8) -> Style {
194 self.attrs.fg = Some(Color::Rgb(r, g, b));
195 self
196 }
197
198 /// Render the text in bold.
199 ///
200 /// # Examples
201 ///
202 /// ```
203 /// use cli_forge::style;
204 ///
205 /// let heading = style("Summary").bold();
206 /// assert!(heading.render().contains("Summary"));
207 /// ```
208 #[must_use]
209 pub fn bold(mut self) -> Style {
210 self.attrs.bold = true;
211 self
212 }
213
214 /// Underline the text.
215 ///
216 /// # Examples
217 ///
218 /// ```
219 /// use cli_forge::style;
220 ///
221 /// let link = style("docs").underline();
222 /// assert!(link.render().contains("docs"));
223 /// ```
224 #[must_use]
225 pub fn underline(mut self) -> Style {
226 self.attrs.underline = true;
227 self
228 }
229
230 /// Render to an owned `String`, ready to print or store.
231 ///
232 /// Equivalent to formatting the `Style` via its [`Display`] implementation.
233 /// The color depth matches the terminal detected for standard output, so on
234 /// a pipe or a `NO_COLOR` environment the result is the plain text.
235 ///
236 /// # Examples
237 ///
238 /// ```
239 /// use cli_forge::style;
240 ///
241 /// let s = style("ready").green().render();
242 /// assert!(s.contains("ready"));
243 /// ```
244 #[must_use]
245 pub fn render(&self) -> String {
246 let mut buf = String::with_capacity(self.text.len() + STYLE_OVERHEAD);
247 // Writing to a `String` is infallible; the `fmt::Result` cannot be `Err`.
248 let _ = write_styled(&mut buf, self.attrs, &self.text, terminal::color_level());
249 buf
250 }
251
252 /// The attribute set, for the registry to capture a reusable style.
253 pub(crate) fn attrs(&self) -> StyleAttrs {
254 self.attrs
255 }
256}
257
258/// A generous upper bound on the escape-sequence bytes wrapping one styled run,
259/// used to size the render buffer so the common case needs no reallocation.
260const STYLE_OVERHEAD: usize = 24;
261
262impl Display for Style {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 write_styled(f, self.attrs, &self.text, terminal::color_level())
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 #![allow(clippy::unwrap_used)]
271
272 use super::*;
273
274 /// Render an attribute set at an explicit level, bypassing terminal
275 /// detection so the bytes are deterministic in the test harness.
276 fn render_at(attrs: StyleAttrs, text: &str, level: ColorLevel) -> String {
277 let mut s = String::new();
278 write_styled(&mut s, attrs, text, level).unwrap();
279 s
280 }
281
282 #[test]
283 fn test_empty_style_is_plain_even_with_color() {
284 let attrs = StyleAttrs::EMPTY;
285 assert_eq!(render_at(attrs, "hello", ColorLevel::TrueColor), "hello");
286 }
287
288 #[test]
289 fn test_none_level_strips_all_styling() {
290 let attrs = StyleAttrs {
291 fg: Some(Color::Red),
292 bold: true,
293 underline: true,
294 };
295 assert_eq!(render_at(attrs, "x", ColorLevel::None), "x");
296 }
297
298 #[test]
299 fn test_canonical_parameter_order_is_bold_underline_color() {
300 let attrs = StyleAttrs {
301 fg: Some(Color::Red),
302 bold: true,
303 underline: true,
304 };
305 assert_eq!(
306 render_at(attrs, "ERR", ColorLevel::Ansi16),
307 "\x1b[1;4;31mERR\x1b[0m"
308 );
309 }
310
311 #[test]
312 fn test_builder_order_does_not_change_bytes() {
313 // Setting attributes in different orders yields the same canonical bytes.
314 let a = style("ERR").red().bold().underline();
315 let b = style("ERR").underline().bold().red();
316 assert_eq!(
317 render_at(a.attrs(), "ERR", ColorLevel::Ansi16),
318 render_at(b.attrs(), "ERR", ColorLevel::Ansi16)
319 );
320 }
321
322 #[test]
323 fn test_single_attribute_has_no_stray_separator() {
324 let bold = StyleAttrs {
325 fg: None,
326 bold: true,
327 underline: false,
328 };
329 assert_eq!(render_at(bold, "x", ColorLevel::Ansi16), "\x1b[1mx\x1b[0m");
330 let red = StyleAttrs {
331 fg: Some(Color::Red),
332 bold: false,
333 underline: false,
334 };
335 assert_eq!(render_at(red, "x", ColorLevel::Ansi16), "\x1b[31mx\x1b[0m");
336 }
337
338 #[test]
339 fn test_invalid_hex_leaves_color_unset() {
340 assert_eq!(style("x").hex("zzzzzz").attrs().fg, None);
341 assert_eq!(
342 style("x").hex("#abcdef").attrs().fg,
343 Some(Color::Rgb(171, 205, 239))
344 );
345 }
346}