Skip to main content

photon_ui/theme/
style.rs

1//! Composite text styles for terminal rendering.
2//!
3//! A [`Style`] bundles foreground color, background color, and text
4//! attributes (bold, italic, underline, dim) into a single unit that
5//! can be applied to strings via [`stylize`].
6
7use super::{
8    Color,
9    ColorMode,
10    ansi,
11};
12
13/// A terminal text style: colors + attributes.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
15pub struct Style {
16    /// Foreground color.
17    pub fg: Option<Color>,
18    /// Background color.
19    pub bg: Option<Color>,
20    /// Bold text attribute.
21    pub bold: bool,
22    /// Italic text attribute.
23    pub italic: bool,
24    /// Underline text attribute.
25    pub underline: bool,
26    /// Dim / faint text attribute.
27    pub dim: bool,
28}
29
30impl Style {
31    /// Create a new default style.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Set the foreground color.
37    pub fn fg(mut self, color: Color) -> Self {
38        self.fg = Some(color);
39        self
40    }
41
42    /// Set the background color.
43    pub fn bg(mut self, color: Color) -> Self {
44        self.bg = Some(color);
45        self
46    }
47
48    /// Enable bold text.
49    pub fn bold(mut self) -> Self {
50        self.bold = true;
51        self
52    }
53
54    /// Enable italic text.
55    pub fn italic(mut self) -> Self {
56        self.italic = true;
57        self
58    }
59
60    /// Enable underlined text.
61    pub fn underline(mut self) -> Self {
62        self.underline = true;
63        self
64    }
65
66    /// Enable dim text.
67    pub fn dim(mut self) -> Self {
68        self.dim = true;
69        self
70    }
71
72    /// Generate the ANSI escape prefix for this style.
73    pub fn prefix(&self, mode: ColorMode) -> String {
74        let mut parts = Vec::new();
75        if let Some(c) = self.fg {
76            parts.push(ansi::fg(c, mode));
77        }
78        if let Some(c) = self.bg {
79            parts.push(ansi::bg(c, mode));
80        }
81        if self.bold {
82            parts.push("\x1b[1m".to_string());
83        }
84        if self.dim {
85            parts.push("\x1b[2m".to_string());
86        }
87        if self.italic {
88            parts.push("\x1b[3m".to_string());
89        }
90        if self.underline {
91            parts.push("\x1b[4m".to_string());
92        }
93        parts.concat()
94    }
95
96    /// The ANSI reset suffix.
97    ///
98    /// When the style includes bold or faint, the suffix emits `\x1b[22m`
99    /// before `\x1b[0m`. Some macOS terminals do not reliably clear bold on a
100    /// full reset alone, so the explicit intensity reset prevents bold from
101    /// leaking into subsequent text.
102    pub fn suffix(&self) -> &'static str {
103        if self.bold || self.dim {
104            "\x1b[22m\x1b[0m"
105        } else {
106            ansi::RESET
107        }
108    }
109}
110
111/// Wrap `text` with the ANSI codes for `style`, automatically resetting
112/// at the end. Respects the active color mode.
113pub fn stylize(text: &str, style: &Style) -> String {
114    let mode = ColorMode::detect();
115    format!("{}{}{}", style.prefix(mode), text, style.suffix())
116}
117
118/// Like [`stylize`], but pads `text` with `pad` spaces on each side.
119pub fn stylize_padded(text: &str, style: &Style, pad: usize) -> String {
120    let padding = " ".repeat(pad);
121    stylize(&format!("{}{}{}", padding, text, padding), style)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn style_builder() {
130        let s = Style::new().fg(Color::SUNBEAM_ORANGE).bold();
131        assert_eq!(s.fg, Some(Color::SUNBEAM_ORANGE));
132        assert!(s.bold);
133        assert!(!s.italic);
134    }
135
136    #[test]
137    fn stylize_produces_codes() {
138        let s = Style::new().fg(Color::SUNBEAM_ORANGE);
139        let out = stylize("hi", &s);
140        assert!(out.contains("hi"));
141        assert!(out.starts_with('\x1b'));
142        assert!(out.ends_with("\x1b[0m"));
143    }
144
145    #[test]
146    fn stylize_padded_applies_padding() {
147        let s = Style::new().fg(Color::WHITE);
148        let out = stylize_padded("ok", &s, 2);
149        assert!(out.contains("  ok  "));
150    }
151
152    /// Regression: bold styles must emit an explicit intensity reset before the
153    /// full reset to prevent bold from leaking on macOS terminals.
154    #[test]
155    fn stylize_bold_emits_bold_off() {
156        let s = Style::new().bold();
157        let out = stylize("hi", &s);
158        assert!(out.ends_with("\x1b[22m\x1b[0m"));
159    }
160
161    /// Non-bold styles should keep the plain reset suffix.
162    #[test]
163    fn stylize_non_bold_uses_plain_reset() {
164        let s = Style::new().fg(Color::WHITE);
165        let out = stylize("hi", &s);
166        assert!(out.ends_with("\x1b[0m"));
167        assert!(!out.contains("\x1b[22m"));
168    }
169
170    #[test]
171    fn style_italic_prefix_and_suffix() {
172        let s = Style::new().italic();
173        let out = stylize("hi", &s);
174        assert!(out.contains("\x1b[3m"));
175        assert!(out.ends_with("\x1b[0m"));
176    }
177
178    #[test]
179    fn style_dim_prefix_and_suffix() {
180        let s = Style::new().dim();
181        let out = stylize("hi", &s);
182        assert!(out.contains("\x1b[2m"));
183        assert!(out.ends_with("\x1b[22m\x1b[0m"));
184    }
185
186    #[test]
187    fn style_full_prefix() {
188        let s = Style::new()
189            .fg(Color::WHITE)
190            .bg(Color::BLACK)
191            .bold()
192            .italic()
193            .underline()
194            .dim();
195        let prefix = s.prefix(ColorMode::TrueColor);
196        assert!(prefix.contains("\x1b[38;2;255;255;255m"));
197        assert!(prefix.contains("\x1b[48;2;0;0;0m"));
198        assert!(prefix.contains("\x1b[1m"));
199        assert!(prefix.contains("\x1b[2m"));
200        assert!(prefix.contains("\x1b[3m"));
201        assert!(prefix.contains("\x1b[4m"));
202    }
203}