Skip to main content

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}