tuxtui_core/
style.rs

1//! Style primitives for terminal text and widgets.
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6/// Error type for color parsing.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ParseColorError {
9    input: alloc::string::String,
10}
11
12impl core::fmt::Display for ParseColorError {
13    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
14        write!(f, "invalid color string: '{}'", self.input)
15    }
16}
17
18#[cfg(feature = "std")]
19impl std::error::Error for ParseColorError {}
20
21/// Terminal colors supporting indexed, RGB, and named colors.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
24pub enum Color {
25    /// Reset to default terminal color
26    Reset,
27    /// Black (0)
28    Black,
29    /// Red (1)
30    Red,
31    /// Green (2)
32    Green,
33    /// Yellow (3)
34    Yellow,
35    /// Blue (4)
36    Blue,
37    /// Magenta (5)
38    Magenta,
39    /// Cyan (6)
40    Cyan,
41    /// White/Gray (7)
42    White,
43    /// Bright black/gray (8)
44    Gray,
45    /// Bright red (9)
46    LightRed,
47    /// Bright green (10)
48    LightGreen,
49    /// Bright yellow (11)
50    LightYellow,
51    /// Bright blue (12)
52    LightBlue,
53    /// Bright magenta (13)
54    LightMagenta,
55    /// Bright cyan (14)
56    LightCyan,
57    /// Bright white (15)
58    LightGray,
59    /// 8-bit indexed color (0-255)
60    Indexed(u8),
61    /// 24-bit RGB color
62    Rgb(u8, u8, u8),
63}
64
65impl Color {
66    /// Create an RGB color.
67    ///
68    /// # Example
69    ///
70    /// ```
71    /// use tuxtui_core::style::Color;
72    ///
73    /// let color = Color::rgb(255, 128, 0);
74    /// ```
75    #[inline]
76    #[must_use]
77    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
78        Self::Rgb(r, g, b)
79    }
80
81    /// Create an indexed color (0-255).
82    #[inline]
83    #[must_use]
84    pub const fn indexed(index: u8) -> Self {
85        Self::Indexed(index)
86    }
87
88    /// Parse a color from a string.
89    ///
90    /// Supports:
91    /// - Named colors: "red", "blue", "green", etc.
92    /// - Hex colors: "#FF0000", "#F00"
93    /// - RGB: "rgb(255, 0, 0)"
94    /// - Indexed: "0" through "255"
95    ///
96    /// # Example
97    ///
98    /// ```
99    /// use tuxtui_core::style::Color;
100    ///
101    /// let red = Color::parse("red").unwrap();
102    /// let hex = Color::parse("#FF0000").unwrap();
103    /// let rgb = Color::parse("rgb(255, 0, 0)").unwrap();
104    /// ```
105    pub fn parse(s: &str) -> Result<Self, ParseColorError> {
106        let s = s.trim().to_lowercase();
107
108        // Named colors
109        match s.as_str() {
110            "reset" => return Ok(Self::Reset),
111            "black" => return Ok(Self::Black),
112            "red" => return Ok(Self::Red),
113            "green" => return Ok(Self::Green),
114            "yellow" => return Ok(Self::Yellow),
115            "blue" => return Ok(Self::Blue),
116            "magenta" => return Ok(Self::Magenta),
117            "cyan" => return Ok(Self::Cyan),
118            "white" => return Ok(Self::White),
119            "gray" | "grey" => return Ok(Self::Gray),
120            "lightred" | "light_red" => return Ok(Self::LightRed),
121            "lightgreen" | "light_green" => return Ok(Self::LightGreen),
122            "lightyellow" | "light_yellow" => return Ok(Self::LightYellow),
123            "lightblue" | "light_blue" => return Ok(Self::LightBlue),
124            "lightmagenta" | "light_magenta" => return Ok(Self::LightMagenta),
125            "lightcyan" | "light_cyan" => return Ok(Self::LightCyan),
126            "lightgray" | "light_gray" | "lightgrey" | "light_grey" => return Ok(Self::LightGray),
127            _ => {}
128        }
129
130        // Hex colors (#RGB or #RRGGBB)
131        if let Some(hex) = s.strip_prefix('#') {
132            return Self::parse_hex(hex).ok_or(ParseColorError { input: s });
133        }
134
135        // RGB format: rgb(r, g, b)
136        if let Some(rgb) = s.strip_prefix("rgb(") {
137            if let Some(rgb) = rgb.strip_suffix(')') {
138                return Self::parse_rgb(rgb).ok_or(ParseColorError { input: s });
139            }
140        }
141
142        // Indexed color (0-255)
143        if let Ok(index) = s.parse::<u8>() {
144            return Ok(Self::Indexed(index));
145        }
146
147        Err(ParseColorError { input: s })
148    }
149
150    fn parse_hex(hex: &str) -> Option<Self> {
151        match hex.len() {
152            3 => {
153                // #RGB -> #RRGGBB
154                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
155                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
156                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
157                Some(Self::Rgb(r, g, b))
158            }
159            6 => {
160                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
161                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
162                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
163                Some(Self::Rgb(r, g, b))
164            }
165            _ => None,
166        }
167    }
168
169    fn parse_rgb(rgb: &str) -> Option<Self> {
170        let parts: alloc::vec::Vec<&str> = rgb.split(',').map(str::trim).collect();
171        if parts.len() != 3 {
172            return None;
173        }
174        let r = parts[0].parse().ok()?;
175        let g = parts[1].parse().ok()?;
176        let b = parts[2].parse().ok()?;
177        Some(Self::Rgb(r, g, b))
178    }
179}
180
181impl Default for Color {
182    fn default() -> Self {
183        Self::Reset
184    }
185}
186
187impl core::str::FromStr for Color {
188    type Err = ParseColorError;
189
190    fn from_str(s: &str) -> Result<Self, Self::Err> {
191        Self::parse(s)
192    }
193}
194
195bitflags::bitflags! {
196    /// Text style modifiers (bold, italic, underline, etc.).
197    ///
198    /// Multiple modifiers can be combined using bitwise OR.
199    ///
200    /// # Example
201    ///
202    /// ```
203    /// use tuxtui_core::style::Modifier;
204    ///
205    /// let mods = Modifier::BOLD | Modifier::ITALIC;
206    /// assert!(mods.contains(Modifier::BOLD));
207    /// ```
208    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
209    #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
210    pub struct Modifier: u16 {
211        /// Bold text
212        const BOLD              = 0b0000_0000_0001;
213        /// Dimmed text
214        const DIM               = 0b0000_0000_0010;
215        /// Italic text
216        const ITALIC            = 0b0000_0000_0100;
217        /// Underlined text
218        const UNDERLINED        = 0b0000_0000_1000;
219        /// Slow blink
220        const SLOW_BLINK        = 0b0000_0001_0000;
221        /// Rapid blink
222        const RAPID_BLINK       = 0b0000_0010_0000;
223        /// Reverse video (swap fg/bg)
224        const REVERSED          = 0b0000_0100_0000;
225        /// Hidden/invisible text
226        const HIDDEN            = 0b0000_1000_0000;
227        /// Strikethrough text
228        const CROSSED_OUT       = 0b0001_0000_0000;
229    }
230}
231
232impl Default for Modifier {
233    fn default() -> Self {
234        Self::empty()
235    }
236}
237
238/// A complete style specification for text or widgets.
239///
240/// Styles can be composed and merged, with later values taking precedence.
241///
242/// # Example
243///
244/// ```
245/// use tuxtui_core::style::{Color, Style, Modifier};
246///
247/// let style = Style::default()
248///     .fg(Color::Blue)
249///     .bg(Color::Black)
250///     .add_modifier(Modifier::BOLD);
251/// ```
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
253#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
254pub struct Style {
255    /// Foreground color
256    pub fg: Option<Color>,
257    /// Background color
258    pub bg: Option<Color>,
259    /// Underline color (if `underline-color` feature enabled)
260    #[cfg(feature = "underline-color")]
261    pub underline_color: Option<Color>,
262    /// Text modifiers
263    pub add_modifier: Modifier,
264    /// Modifiers to remove
265    pub sub_modifier: Modifier,
266}
267
268impl Style {
269    /// Create a new default style.
270    #[inline]
271    #[must_use]
272    pub const fn new() -> Self {
273        Self {
274            fg: None,
275            bg: None,
276            #[cfg(feature = "underline-color")]
277            underline_color: None,
278            add_modifier: Modifier::empty(),
279            sub_modifier: Modifier::empty(),
280        }
281    }
282
283    /// Set the foreground color.
284    #[inline]
285    #[must_use]
286    pub const fn fg(mut self, color: Color) -> Self {
287        self.fg = Some(color);
288        self
289    }
290
291    /// Set the background color.
292    #[inline]
293    #[must_use]
294    pub const fn bg(mut self, color: Color) -> Self {
295        self.bg = Some(color);
296        self
297    }
298
299    /// Set the underline color (requires `underline-color` feature).
300    #[cfg(feature = "underline-color")]
301    #[inline]
302    #[must_use]
303    pub const fn underline_color(mut self, color: Color) -> Self {
304        self.underline_color = Some(color);
305        self
306    }
307
308    /// Add modifiers.
309    #[inline]
310    #[must_use]
311    pub const fn add_modifier(mut self, modifier: Modifier) -> Self {
312        self.add_modifier = self.add_modifier.union(modifier);
313        self
314    }
315
316    /// Remove modifiers.
317    #[inline]
318    #[must_use]
319    pub const fn remove_modifier(mut self, modifier: Modifier) -> Self {
320        self.sub_modifier = self.sub_modifier.union(modifier);
321        self
322    }
323
324    /// Reset the style to default.
325    #[inline]
326    #[must_use]
327    pub const fn reset() -> Self {
328        Self::new()
329    }
330
331    /// Patch this style with another, taking non-None values from `other`.
332    ///
333    /// # Example
334    ///
335    /// ```
336    /// use tuxtui_core::style::{Color, Style};
337    ///
338    /// let base = Style::default().fg(Color::Red);
339    /// let patch = Style::default().bg(Color::Blue);
340    /// let merged = base.patch(patch);
341    ///
342    /// assert_eq!(merged.fg, Some(Color::Red));
343    /// assert_eq!(merged.bg, Some(Color::Blue));
344    /// ```
345    #[must_use]
346    pub const fn patch(mut self, other: Self) -> Self {
347        if other.fg.is_some() {
348            self.fg = other.fg;
349        }
350        if other.bg.is_some() {
351            self.bg = other.bg;
352        }
353        #[cfg(feature = "underline-color")]
354        if other.underline_color.is_some() {
355            self.underline_color = other.underline_color;
356        }
357        self.add_modifier = self.add_modifier.union(other.add_modifier);
358        self.sub_modifier = self.sub_modifier.union(other.sub_modifier);
359        self
360    }
361}
362
363/// A trait for types that can be styled.
364///
365/// This provides a fluent API for applying styles to text and widgets.
366///
367/// # Example
368///
369/// ```
370/// use tuxtui_core::style::{Color, Stylize};
371///
372/// let text = "Hello".blue().bold();
373/// ```
374pub trait Stylize: Sized {
375    /// Apply a style to this item.
376    fn style(self, style: Style) -> Self;
377
378    /// Set the foreground color.
379    #[inline]
380    fn fg(self, color: Color) -> Self {
381        self.style(Style::default().fg(color))
382    }
383
384    /// Set the background color.
385    #[inline]
386    fn bg(self, color: Color) -> Self {
387        self.style(Style::default().bg(color))
388    }
389
390    /// Make the text black.
391    #[inline]
392    fn black(self) -> Self {
393        self.fg(Color::Black)
394    }
395
396    /// Make the text red.
397    #[inline]
398    fn red(self) -> Self {
399        self.fg(Color::Red)
400    }
401
402    /// Make the text green.
403    #[inline]
404    fn green(self) -> Self {
405        self.fg(Color::Green)
406    }
407
408    /// Make the text yellow.
409    #[inline]
410    fn yellow(self) -> Self {
411        self.fg(Color::Yellow)
412    }
413
414    /// Make the text blue.
415    #[inline]
416    fn blue(self) -> Self {
417        self.fg(Color::Blue)
418    }
419
420    /// Make the text magenta.
421    #[inline]
422    fn magenta(self) -> Self {
423        self.fg(Color::Magenta)
424    }
425
426    /// Make the text cyan.
427    #[inline]
428    fn cyan(self) -> Self {
429        self.fg(Color::Cyan)
430    }
431
432    /// Make the text white.
433    #[inline]
434    fn white(self) -> Self {
435        self.fg(Color::White)
436    }
437
438    /// Make the text gray.
439    #[inline]
440    fn gray(self) -> Self {
441        self.fg(Color::Gray)
442    }
443
444    /// Make the text bold.
445    #[inline]
446    fn bold(self) -> Self {
447        self.style(Style::default().add_modifier(Modifier::BOLD))
448    }
449
450    /// Make the text dim.
451    #[inline]
452    fn dim(self) -> Self {
453        self.style(Style::default().add_modifier(Modifier::DIM))
454    }
455
456    /// Make the text italic.
457    #[inline]
458    fn italic(self) -> Self {
459        self.style(Style::default().add_modifier(Modifier::ITALIC))
460    }
461
462    /// Make the text underlined.
463    #[inline]
464    fn underlined(self) -> Self {
465        self.style(Style::default().add_modifier(Modifier::UNDERLINED))
466    }
467
468    /// Make the text blink slowly.
469    #[inline]
470    fn slow_blink(self) -> Self {
471        self.style(Style::default().add_modifier(Modifier::SLOW_BLINK))
472    }
473
474    /// Make the text blink rapidly.
475    #[inline]
476    fn rapid_blink(self) -> Self {
477        self.style(Style::default().add_modifier(Modifier::RAPID_BLINK))
478    }
479
480    /// Reverse the foreground and background colors.
481    #[inline]
482    fn reversed(self) -> Self {
483        self.style(Style::default().add_modifier(Modifier::REVERSED))
484    }
485
486    /// Make the text hidden.
487    #[inline]
488    fn hidden(self) -> Self {
489        self.style(Style::default().add_modifier(Modifier::HIDDEN))
490    }
491
492    /// Make the text crossed out.
493    #[inline]
494    fn crossed_out(self) -> Self {
495        self.style(Style::default().add_modifier(Modifier::CROSSED_OUT))
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_color_rgb() {
505        let color = Color::rgb(255, 128, 64);
506        assert_eq!(color, Color::Rgb(255, 128, 64));
507    }
508
509    #[test]
510    fn test_modifier_bitflags() {
511        let mods = Modifier::BOLD | Modifier::ITALIC;
512        assert!(mods.contains(Modifier::BOLD));
513        assert!(mods.contains(Modifier::ITALIC));
514        assert!(!mods.contains(Modifier::UNDERLINED));
515    }
516
517    #[test]
518    fn test_style_patch() {
519        let base = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
520        let patch = Style::default()
521            .bg(Color::Blue)
522            .add_modifier(Modifier::ITALIC);
523        let merged = base.patch(patch);
524
525        assert_eq!(merged.fg, Some(Color::Red));
526        assert_eq!(merged.bg, Some(Color::Blue));
527        assert!(merged.add_modifier.contains(Modifier::BOLD));
528        assert!(merged.add_modifier.contains(Modifier::ITALIC));
529    }
530}