Skip to main content

rusty_rich/
style.rs

1//! Text style — equivalent to Rich's `style.py`.
2//!
3//! A [`Style`] combines foreground/background color with 13 text attributes
4//! (bold, dim, italic, underline, blink, reverse, strike, underline2, frame,
5//! encircle, overline, blink2, conceal), plus optional link and metadata.
6//!
7//! # Quick Example
8//!
9//! ```rust
10//! use rusty_rich::{Style, Color};
11//!
12//! let style = Style::new()
13//!     .color(Color::parse("cyan").unwrap())
14//!     .bgcolor(Color::parse("#1E1E2E").unwrap())
15//!     .bold(true)
16//!     .italic(true);
17//!
18//! // Parse from a string
19//! let parsed = Style::from_str("bold red on blue");
20//! ```
21//!
22//! # Style Combination
23//!
24//! Styles combine left-to-right via [`Style::combine`] with a 3-state attribute
25//! cascade: explicit `true` wins over inherit, explicit `false` resets, and
26//! unset falls through to the parent.
27//!
28//! # StyleStack
29//!
30//! [`StyleStack`] tracks nested style inheritance for markup parsing. Push
31//! a style when entering a tag, pop when leaving.
32
33use std::fmt;
34use std::hash::{Hash, Hasher};
35use std::sync::atomic::{AtomicU32, Ordering};
36
37use crate::color::{Color, ColorType, EIGHT_BIT_PALETTE, STANDARD_COLOR_NAMES, STANDARD_PALETTE};
38
39static NEXT_ID: AtomicU32 = AtomicU32::new(0);
40
41// ---------------------------------------------------------------------------
42// Style attributes — bit flags
43// ---------------------------------------------------------------------------
44
45/// Bit flags for text attributes.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub struct Attributes(u32);
48
49impl Attributes {
50    /// Bit flag for bold text.
51    pub const BOLD: u32 = 1 << 0;
52    /// Bit flag for dim/dark text.
53    pub const DIM: u32 = 1 << 1;
54    /// Bit flag for italic text.
55    pub const ITALIC: u32 = 1 << 2;
56    /// Bit flag for underlined text.
57    pub const UNDERLINE: u32 = 1 << 3;
58    /// Bit flag for blinking text.
59    pub const BLINK: u32 = 1 << 4;
60    /// Bit flag for reverse-video text.
61    pub const REVERSE: u32 = 1 << 5;
62    /// Bit flag for strikethrough text.
63    pub const STRIKE: u32 = 1 << 6;
64    /// Bit flag for double underline.
65    pub const UNDERLINE2: u32 = 1 << 7;
66    /// Bit flag for framed text.
67    pub const FRAME: u32 = 1 << 8;
68    /// Bit flag for encircled text.
69    pub const ENCIRCLE: u32 = 1 << 9;
70    /// Bit flag for overlined text.
71    pub const OVERLINE: u32 = 1 << 10;
72    /// Bit flag for rapid blink.
73    pub const BLINK2: u32 = 1 << 11;
74    /// Bit flag for concealed/hidden text.
75    pub const CONCEAL: u32 = 1 << 12;
76
77    /// Create an empty set of attributes (no flags set).
78    pub const fn empty() -> Self {
79        Self(0)
80    }
81
82    /// Set or clear a specific attribute bit.
83    pub fn set(&mut self, bit: u32, value: bool) {
84        if value {
85            self.0 |= bit;
86        } else {
87            self.0 &= !bit;
88        }
89    }
90
91    /// Check whether a specific attribute bit is set.
92    pub fn get(&self, bit: u32) -> bool {
93        self.0 & bit != 0
94    }
95
96    /// Return the raw bitmask value.
97    pub const fn bits(&self) -> u32 {
98        self.0
99    }
100}
101
102/// All 13 style attribute bits in order (for iteration).
103pub const STYLE_BITS: &[u32] = &[
104    Attributes::BOLD,
105    Attributes::DIM,
106    Attributes::ITALIC,
107    Attributes::UNDERLINE,
108    Attributes::BLINK,
109    Attributes::REVERSE,
110    Attributes::STRIKE,
111    Attributes::UNDERLINE2,
112    Attributes::FRAME,
113    Attributes::ENCIRCLE,
114    Attributes::OVERLINE,
115    Attributes::BLINK2,
116    Attributes::CONCEAL,
117];
118
119/// All 13 style attribute (name, bit) pairs for iteration.
120pub const STYLE_ATTRIBUTES: &[(&str, u32)] = &[
121    ("bold", Attributes::BOLD),
122    ("dim", Attributes::DIM),
123    ("italic", Attributes::ITALIC),
124    ("underline", Attributes::UNDERLINE),
125    ("blink", Attributes::BLINK),
126    ("reverse", Attributes::REVERSE),
127    ("strike", Attributes::STRIKE),
128    ("underline2", Attributes::UNDERLINE2),
129    ("frame", Attributes::FRAME),
130    ("encircle", Attributes::ENCIRCLE),
131    ("overline", Attributes::OVERLINE),
132    ("blink2", Attributes::BLINK2),
133    ("conceal", Attributes::CONCEAL),
134];
135
136impl fmt::Display for Attributes {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        let mut parts: Vec<&str> = Vec::new();
139        if self.get(Self::BOLD) {
140            parts.push("bold");
141        }
142        if self.get(Self::DIM) {
143            parts.push("dim");
144        }
145        if self.get(Self::ITALIC) {
146            parts.push("italic");
147        }
148        if self.get(Self::UNDERLINE) {
149            parts.push("underline");
150        }
151        if self.get(Self::BLINK) {
152            parts.push("blink");
153        }
154        if self.get(Self::REVERSE) {
155            parts.push("reverse");
156        }
157        if self.get(Self::CONCEAL) {
158            parts.push("conceal");
159        }
160        if self.get(Self::STRIKE) {
161            parts.push("strike");
162        }
163        if self.get(Self::OVERLINE) {
164            parts.push("overline");
165        }
166        if parts.is_empty() {
167            write!(f, "none")
168        } else {
169            write!(f, "{}", parts.join(" "))
170        }
171    }
172}
173
174// ---------------------------------------------------------------------------
175// Style
176// ---------------------------------------------------------------------------
177
178/// A constant null (empty) style. Use instead of `Style::null()` to avoid
179/// allocation when you need a null style repeatedly.
180pub const NULL_STYLE: Style = Style {
181    color: None,
182    bgcolor: None,
183    attributes: Attributes(0),
184    set_attributes: 0,
185    link: None,
186    link_id: 0,
187    is_null: true,
188    meta: None,
189};
190
191/// A terminal style.
192///
193/// Supports foreground color, background color, attributes, and an optional
194/// hyperlink. Attributes use a three-state system: set to `true`, set to
195/// `false`, or not set (`None`).
196#[derive(Debug, Clone)]
197pub struct Style {
198    pub(crate) color: Option<Color>,
199    pub(crate) bgcolor: Option<Color>,
200    pub(crate) attributes: Attributes,
201    /// Which attribute bits have been explicitly set (vs inherited).
202    pub(crate) set_attributes: u32,
203    pub(crate) link: Option<String>,
204    pub(crate) link_id: u32,
205    pub(crate) is_null: bool,
206    /// Arbitrary metadata attached to this style.
207    pub(crate) meta: Option<Vec<u8>>,
208}
209
210impl Style {
211    // -- constructors -------------------------------------------------------
212
213    /// Create a null (empty) style.
214    ///
215    /// For a zero-allocation alternative, use the [`NULL_STYLE`] constant
216    /// directly or clone it.
217    pub fn null() -> Self {
218        NULL_STYLE.clone()
219    }
220
221    /// Create a new style with optional settings.
222    pub fn new() -> Self {
223        Self {
224            color: None,
225            bgcolor: None,
226            attributes: Attributes::empty(),
227            set_attributes: 0,
228            link: None,
229            link_id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
230            is_null: false,
231            meta: None,
232        }
233    }
234
235    /// Builder: set foreground color.
236    pub fn color(mut self, color: impl Into<Option<Color>>) -> Self {
237        self.color = color.into();
238        self
239    }
240
241    /// Builder: set background color.
242    pub fn bgcolor(mut self, bgcolor: impl Into<Option<Color>>) -> Self {
243        self.bgcolor = bgcolor.into();
244        self
245    }
246
247    /// Builder: set bold.
248    pub fn bold(mut self, value: bool) -> Self {
249        self.set_attributes |= Attributes::BOLD;
250        self.attributes.set(Attributes::BOLD, value);
251        self
252    }
253
254    /// Builder: set dim.
255    pub fn dim(mut self, value: bool) -> Self {
256        self.set_attributes |= Attributes::DIM;
257        self.attributes.set(Attributes::DIM, value);
258        self
259    }
260
261    /// Builder: set italic.
262    pub fn italic(mut self, value: bool) -> Self {
263        self.set_attributes |= Attributes::ITALIC;
264        self.attributes.set(Attributes::ITALIC, value);
265        self
266    }
267
268    /// Builder: set underline.
269    pub fn underline(mut self, value: bool) -> Self {
270        self.set_attributes |= Attributes::UNDERLINE;
271        self.attributes.set(Attributes::UNDERLINE, value);
272        self
273    }
274
275    /// Builder: set blink.
276    pub fn blink(mut self, value: bool) -> Self {
277        self.set_attributes |= Attributes::BLINK;
278        self.attributes.set(Attributes::BLINK, value);
279        self
280    }
281
282    /// Builder: set reverse.
283    pub fn reverse(mut self, value: bool) -> Self {
284        self.set_attributes |= Attributes::REVERSE;
285        self.attributes.set(Attributes::REVERSE, value);
286        self
287    }
288
289    /// Builder: set strikethrough.
290    pub fn strike(mut self, value: bool) -> Self {
291        self.set_attributes |= Attributes::STRIKE;
292        self.attributes.set(Attributes::STRIKE, value);
293        self
294    }
295
296    /// Builder: set blink2 (rapid blink).
297    pub fn blink2(mut self, value: bool) -> Self {
298        self.set_attributes |= Attributes::BLINK2;
299        self.attributes.set(Attributes::BLINK2, value);
300        self
301    }
302
303    /// Builder: set conceal.
304    pub fn conceal(mut self, value: bool) -> Self {
305        self.set_attributes |= Attributes::CONCEAL;
306        self.attributes.set(Attributes::CONCEAL, value);
307        self
308    }
309
310    /// Builder: set double underline.
311    pub fn underline2(mut self, value: bool) -> Self {
312        self.set_attributes |= Attributes::UNDERLINE2;
313        self.attributes.set(Attributes::UNDERLINE2, value);
314        self
315    }
316
317    /// Builder: set frame.
318    pub fn frame(mut self, value: bool) -> Self {
319        self.set_attributes |= Attributes::FRAME;
320        self.attributes.set(Attributes::FRAME, value);
321        self
322    }
323
324    /// Builder: set encircle.
325    pub fn encircle(mut self, value: bool) -> Self {
326        self.set_attributes |= Attributes::ENCIRCLE;
327        self.attributes.set(Attributes::ENCIRCLE, value);
328        self
329    }
330
331    /// Builder: set overline.
332    pub fn overline(mut self, value: bool) -> Self {
333        self.set_attributes |= Attributes::OVERLINE;
334        self.attributes.set(Attributes::OVERLINE, value);
335        self
336    }
337
338    /// Return a copy with foreground and background colors stripped.
339    pub fn without_color(&self) -> Self {
340        let mut s = self.clone();
341        s.color = None;
342        s.bgcolor = None;
343        s
344    }
345
346    /// Return a style with the background color set to the foreground color,
347    /// useful for background-only rendering.
348    pub fn background_style(&self) -> Self {
349        let mut s = Self::new();
350        s.bgcolor = self.color;
351        s
352    }
353
354    /// Returns true if the background is not set (transparent).
355    pub fn transparent_background(&self) -> bool {
356        self.bgcolor.is_none()
357    }
358
359    /// Builder: set link.
360    pub fn link(mut self, url: impl Into<String>) -> Self {
361        self.link = Some(url.into());
362        self.link_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
363        self
364    }
365
366    /// Builder: set style from a string (e.g. "bold red on blue").
367    ///
368    /// Supports negation with `not` prefix, `!` prefix, and `no` prefix
369    /// (e.g. `"not bold"`, `"!bold"`, `"nobold"`). The `not` keyword works
370    /// as a prefix for the NEXT token, so `"not bold not italic"` correctly
371    /// disables both attributes.
372    #[allow(clippy::should_implement_trait)]
373    pub fn from_str(definition: &str) -> Self {
374        let mut style = Self::new();
375        let parts: Vec<&str> = definition.split_whitespace().collect();
376        let mut i = 0;
377        let mut negate = false;
378        let mut saw_on = false;
379
380        while i < parts.len() {
381            let part = parts[i];
382
383            // Handle standalone "not" prefix (negates next token)
384            if part == "not" {
385                negate = true;
386                i += 1;
387                continue;
388            }
389
390            match part {
391                "bold" | "b" => {
392                    style.set_attributes |= Attributes::BOLD;
393                    style.attributes.set(Attributes::BOLD, !negate);
394                }
395                "dim" | "d" => {
396                    style.set_attributes |= Attributes::DIM;
397                    style.attributes.set(Attributes::DIM, !negate);
398                }
399                "italic" | "i" => {
400                    style.set_attributes |= Attributes::ITALIC;
401                    style.attributes.set(Attributes::ITALIC, !negate);
402                }
403                "underline" | "u" => {
404                    style.set_attributes |= Attributes::UNDERLINE;
405                    style.attributes.set(Attributes::UNDERLINE, !negate);
406                }
407                "blink" => {
408                    style.set_attributes |= Attributes::BLINK;
409                    style.attributes.set(Attributes::BLINK, !negate);
410                }
411                "reverse" | "r" => {
412                    style.set_attributes |= Attributes::REVERSE;
413                    style.attributes.set(Attributes::REVERSE, !negate);
414                }
415                "strike" | "s" => {
416                    style.set_attributes |= Attributes::STRIKE;
417                    style.attributes.set(Attributes::STRIKE, !negate);
418                }
419                "none" | "default" => {}
420                "on" => {
421                    saw_on = true;
422                    // If next token is a color, consume it as background
423                    if i + 1 < parts.len() {
424                        if let Ok(c) = Color::parse(parts[i + 1]) {
425                            style.bgcolor = Some(c);
426                            i += 1; // consumed the color token
427                        }
428                    }
429                }
430                part if part.starts_with('!') => {
431                    // Inline negation: !bold, !italic, etc.
432                    let inner = &part[1..];
433                    let (bit, _name) = match inner {
434                        "bold" | "b" => (Attributes::BOLD, "bold"),
435                        "dim" | "d" => (Attributes::DIM, "dim"),
436                        "italic" | "i" => (Attributes::ITALIC, "italic"),
437                        "underline" | "u" => (Attributes::UNDERLINE, "underline"),
438                        "blink" => (Attributes::BLINK, "blink"),
439                        "reverse" | "r" => (Attributes::REVERSE, "reverse"),
440                        "strike" | "s" => (Attributes::STRIKE, "strike"),
441                        _ => {
442                            // Not a known attribute — skip
443                            i += 1;
444                            negate = false;
445                            continue;
446                        }
447                    };
448                    style.set_attributes |= bit;
449                    style.attributes.set(bit, false);
450                }
451                part if part.starts_with("no") && part.len() > 2 => {
452                    // Prefix negation: nobold, noitalic, nounderline
453                    let inner = &part[2..];
454                    let (bit, _name) = match inner {
455                        "bold" => (Attributes::BOLD, "bold"),
456                        "italic" => (Attributes::ITALIC, "italic"),
457                        "underline" => (Attributes::UNDERLINE, "underline"),
458                        _ => {
459                            // Not a known attribute — skip
460                            i += 1;
461                            negate = false;
462                            continue;
463                        }
464                    };
465                    style.set_attributes |= bit;
466                    style.attributes.set(bit, false);
467                }
468                part if part.starts_with("link=") => {
469                    style.link = Some(part[5..].to_string());
470                }
471                part if part.starts_with("on ") => {
472                    if let Ok(c) = Color::parse(&part[3..]) {
473                        style.bgcolor = Some(c);
474                    }
475                }
476                part => {
477                    // Try as color name
478                    if let Ok(c) = Color::parse(part) {
479                        if saw_on {
480                            style.bgcolor = Some(c);
481                            saw_on = false;
482                        } else {
483                            style.color = Some(c);
484                        }
485                    }
486                }
487            }
488            negate = false;
489            i += 1;
490        }
491        style
492    }
493
494    // -- queries ------------------------------------------------------------
495
496    /// Returns `true` if this is a null (empty) style.
497    pub fn is_null(&self) -> bool {
498        self.is_null
499    }
500
501    /// Returns `true` if this style has no colors, attributes, or link set.
502    pub fn is_plain(&self) -> bool {
503        self.color.is_none()
504            && self.bgcolor.is_none()
505            && self.set_attributes == 0
506            && self.link.is_none()
507    }
508
509    /// Check if the bold attribute is explicitly set to `true`.
510    pub fn get_bold(&self) -> Option<bool> {
511        if self.set_attributes & Attributes::BOLD != 0 {
512            Some(self.attributes.get(Attributes::BOLD))
513        } else {
514            None
515        }
516    }
517
518    /// Check if the italic attribute is explicitly set to `true`.
519    pub fn get_italic(&self) -> Option<bool> {
520        if self.set_attributes & Attributes::ITALIC != 0 {
521            Some(self.attributes.get(Attributes::ITALIC))
522        } else {
523            None
524        }
525    }
526
527    /// Merge two styles: `self` is the base, `other` overrides.
528    pub fn combine(&self, other: &Style) -> Style {
529        if other.is_null {
530            return self.clone();
531        }
532        if self.is_null {
533            return other.clone();
534        }
535
536        let mut combined = self.clone();
537        if other.color.is_some() {
538            combined.color = other.color;
539        }
540        if other.bgcolor.is_some() {
541            combined.bgcolor = other.bgcolor;
542        }
543        // Attributes: other's set bits override self (3-state cascade)
544        for &bit in STYLE_BITS {
545            if other.set_attributes & bit != 0 {
546                combined.set_attributes |= bit;
547                combined.attributes.set(bit, other.attributes.get(bit));
548            }
549        }
550        if other.link.is_some() {
551            combined.link = other.link.clone();
552            combined.link_id = other.link_id;
553        }
554        if other.meta.is_some() {
555            combined.meta = other.meta.clone();
556        }
557        combined.is_null = false;
558        combined
559    }
560
561    /// Render this style as ANSI SGR escape sequences.
562    ///
563    /// Uses a pre-allocated `String` with direct `push_str` instead of
564    /// `Vec<String>` + `join()`, avoiding ~3× allocations in the render
565    /// hot path.
566    pub fn to_ansi(&self) -> String {
567        if self.is_null {
568            return String::new();
569        }
570        let mut out = String::with_capacity(48);
571        let mut first = true;
572
573        // Macro to push a simple code with proper separator
574        macro_rules! push_code {
575            ($code:expr) => {{
576                if first {
577                    out.push_str("\x1b[");
578                    first = false;
579                } else {
580                    out.push(';');
581                }
582                out.push_str($code);
583            }};
584        }
585
586        // Foreground color
587        if let Some(ref c) = self.color {
588            match c.color_type {
589                crate::color::ColorType::Default => push_code!("39"),
590                crate::color::ColorType::Standard => {
591                    if let Some(n) = c.number {
592                        let code = if n < 8 { 30 + n } else { 82 + n };
593                        push_code!(&code.to_string());
594                    }
595                }
596                crate::color::ColorType::EightBit => {
597                    if let Some(n) = c.number {
598                        out.push_str(if first { "\x1b[38;5;" } else { ";38;5;" });
599                        first = false;
600                        out.push_str(&n.to_string());
601                    }
602                }
603                crate::color::ColorType::TrueColor => {
604                    if let Some((r, g, b)) = c.triplet {
605                        out.push_str(if first { "\x1b[38;2;" } else { ";38;2;" });
606                        first = false;
607                        out.push_str(&format!("{r};{g};{b}"));
608                    }
609                }
610            }
611        }
612
613        // Background color
614        if let Some(ref c) = self.bgcolor {
615            match c.color_type {
616                crate::color::ColorType::Default => push_code!("49"),
617                crate::color::ColorType::Standard => {
618                    if let Some(n) = c.number {
619                        let code = if n < 8 { 40 + n } else { 92 + n };
620                        push_code!(&code.to_string());
621                    }
622                }
623                crate::color::ColorType::EightBit => {
624                    if let Some(n) = c.number {
625                        out.push_str(if first { "\x1b[48;5;" } else { ";48;5;" });
626                        first = false;
627                        out.push_str(&n.to_string());
628                    }
629                }
630                crate::color::ColorType::TrueColor => {
631                    if let Some((r, g, b)) = c.triplet {
632                        out.push_str(if first { "\x1b[48;2;" } else { ";48;2;" });
633                        first = false;
634                        out.push_str(&format!("{r};{g};{b}"));
635                    }
636                }
637            }
638        }
639
640        // Attributes — use push_code! macro for single-code attributes
641        if self.set_attributes & Attributes::BOLD != 0 {
642            push_code!(if self.attributes.get(Attributes::BOLD) {
643                "1"
644            } else {
645                "22"
646            });
647        }
648        if self.set_attributes & Attributes::DIM != 0 {
649            push_code!(if self.attributes.get(Attributes::DIM) {
650                "2"
651            } else {
652                "22"
653            });
654        }
655        if self.set_attributes & Attributes::ITALIC != 0 {
656            push_code!(if self.attributes.get(Attributes::ITALIC) {
657                "3"
658            } else {
659                "23"
660            });
661        }
662        if self.set_attributes & Attributes::UNDERLINE != 0 {
663            push_code!(if self.attributes.get(Attributes::UNDERLINE) {
664                "4"
665            } else {
666                "24"
667            });
668        }
669        if self.set_attributes & Attributes::BLINK != 0 {
670            push_code!(if self.attributes.get(Attributes::BLINK) {
671                "5"
672            } else {
673                "25"
674            });
675        }
676        if self.set_attributes & Attributes::REVERSE != 0 {
677            push_code!(if self.attributes.get(Attributes::REVERSE) {
678                "7"
679            } else {
680                "27"
681            });
682        }
683        if self.set_attributes & Attributes::CONCEAL != 0 {
684            push_code!(if self.attributes.get(Attributes::CONCEAL) {
685                "8"
686            } else {
687                "28"
688            });
689        }
690        if self.set_attributes & Attributes::STRIKE != 0 {
691            push_code!(if self.attributes.get(Attributes::STRIKE) {
692                "9"
693            } else {
694                "29"
695            });
696        }
697        if self.set_attributes & Attributes::UNDERLINE2 != 0 {
698            push_code!(if self.attributes.get(Attributes::UNDERLINE2) {
699                "21"
700            } else {
701                "24"
702            });
703        }
704        if self.set_attributes & Attributes::BLINK2 != 0 {
705            push_code!(if self.attributes.get(Attributes::BLINK2) {
706                "6"
707            } else {
708                "25"
709            });
710        }
711        if self.set_attributes & Attributes::FRAME != 0 {
712            push_code!(if self.attributes.get(Attributes::FRAME) {
713                "51"
714            } else {
715                "54"
716            });
717        }
718        if self.set_attributes & Attributes::ENCIRCLE != 0 {
719            push_code!(if self.attributes.get(Attributes::ENCIRCLE) {
720                "52"
721            } else {
722                "54"
723            });
724        }
725        if self.set_attributes & Attributes::OVERLINE != 0 {
726            push_code!(if self.attributes.get(Attributes::OVERLINE) {
727                "53"
728            } else {
729                "55"
730            });
731        }
732
733        if !first {
734            out.push('m');
735        }
736        out
737    }
738
739    /// Return the ANSI reset sequence needed to turn off this style.
740    pub fn reset_ansi(&self) -> &'static str {
741        "\x1b[0m"
742    }
743
744    // -- chaining --------------------------------------------------------------
745
746    /// Create a chain-of-styles fallback. When `self` has a value set, use it;
747    /// otherwise fall through to `other`.
748    pub fn chain(&self, other: &Style) -> Style {
749        let mut result = Style::new();
750        result.color = self.color.or(other.color);
751        result.bgcolor = self.bgcolor.or(other.bgcolor);
752        result.link = self.link.clone().or_else(|| other.link.clone());
753        result.meta = self.meta.clone().or_else(|| other.meta.clone());
754        for &bit in STYLE_BITS {
755            if self.set_attributes & bit != 0 {
756                result.set_attributes |= bit;
757                result.attributes.set(bit, self.attributes.get(bit));
758            } else if other.set_attributes & bit != 0 {
759                result.set_attributes |= bit;
760                result.attributes.set(bit, other.attributes.get(bit));
761            }
762        }
763        result
764    }
765
766    // -- copy / clear ----------------------------------------------------------
767
768    /// Explicit clone (delegates to Clone, named for Python parity).
769    pub fn copy(&self) -> Style {
770        self.clone()
771    }
772
773    /// Clear the meta field and link field, returning self for chaining.
774    pub fn clear_meta_and_links(&mut self) -> &mut Self {
775        self.meta = None;
776        self.link = None;
777        self
778    }
779
780    // -- constructors ----------------------------------------------------------
781
782    /// Create a style with just a foreground color set.
783    pub fn from_color(color: Color) -> Self {
784        Self::new().color(color)
785    }
786
787    /// Create a style with metadata.
788    pub fn from_meta(meta: Vec<u8>) -> Self {
789        let mut s = Self::new();
790        s.meta = Some(meta);
791        s
792    }
793
794    // -- html export -----------------------------------------------------------
795
796    /// Generate CSS style string for HTML export.
797    pub fn get_html_style(&self, _theme: Option<&crate::export::ExportTheme>) -> String {
798        if self.is_null {
799            return String::new();
800        }
801        let mut parts: Vec<String> = Vec::new();
802
803        if let Some(ref c) = self.color {
804            let hex = color_to_css_hex(c);
805            if !hex.is_empty() {
806                parts.push(format!("color: {}", hex));
807            }
808        }
809        if let Some(ref c) = self.bgcolor {
810            let hex = color_to_css_hex(c);
811            if !hex.is_empty() {
812                parts.push(format!("background-color: {}", hex));
813            }
814        }
815        if self.set_attributes & Attributes::BOLD != 0 && self.attributes.get(Attributes::BOLD) {
816            parts.push("font-weight: bold".into());
817        }
818        if self.set_attributes & Attributes::ITALIC != 0 && self.attributes.get(Attributes::ITALIC)
819        {
820            parts.push("font-style: italic".into());
821        }
822
823        // text-decoration: combine underline, strike, blink
824        let mut decor: Vec<&str> = Vec::new();
825        if self.set_attributes & Attributes::UNDERLINE != 0
826            && self.attributes.get(Attributes::UNDERLINE)
827        {
828            decor.push("underline");
829        }
830        if self.set_attributes & Attributes::UNDERLINE2 != 0
831            && self.attributes.get(Attributes::UNDERLINE2)
832        {
833            decor.push("underline");
834        }
835        if self.set_attributes & Attributes::STRIKE != 0 && self.attributes.get(Attributes::STRIKE)
836        {
837            decor.push("line-through");
838        }
839        if (self.set_attributes & Attributes::BLINK != 0 && self.attributes.get(Attributes::BLINK))
840            || (self.set_attributes & Attributes::BLINK2 != 0
841                && self.attributes.get(Attributes::BLINK2))
842        {
843            decor.push("blink");
844        }
845        if !decor.is_empty() {
846            parts.push(format!("text-decoration: {}", decor.join(" ")));
847        }
848
849        // reverse video → CSS invert filter
850        if self.set_attributes & Attributes::REVERSE != 0
851            && self.attributes.get(Attributes::REVERSE)
852        {
853            parts.push("filter: invert(100%)".into());
854        }
855
856        // conceal → hidden visibility
857        if self.set_attributes & Attributes::CONCEAL != 0
858            && self.attributes.get(Attributes::CONCEAL)
859        {
860            parts.push("visibility: hidden".into());
861        }
862
863        if parts.is_empty() {
864            String::new()
865        } else {
866            parts.join("; ")
867        }
868    }
869
870    // -- normalize -------------------------------------------------------------
871
872    /// Return a "normalized" style: remove negative (explicitly false) attributes
873    /// that just reset inherited ones. Only keep explicitly true attributes and
874    /// colors.
875    pub fn normalize(&self) -> Style {
876        let mut s = Style::new();
877        s.color = self.color;
878        s.bgcolor = self.bgcolor;
879        s.link = self.link.clone();
880        s.link_id = self.link_id;
881        s.meta = self.meta.clone();
882        for &bit in STYLE_BITS {
883            if self.set_attributes & bit != 0 && self.attributes.get(bit) {
884                s.set_attributes |= bit;
885                s.attributes.set(bit, true);
886            }
887        }
888        s
889    }
890
891    // -- utility ---------------------------------------------------------------
892
893    /// Return the "first" significant color name for display purposes
894    /// (fg color name, or bg color name, or None).
895    pub fn pick_first(&self) -> Option<&'static str> {
896        if let Some(ref c) = self.color {
897            if let Some(name) = color_to_name(c) {
898                return Some(name);
899            }
900        }
901        if let Some(ref c) = self.bgcolor {
902            if let Some(name) = color_to_name(c) {
903                return Some(name);
904            }
905        }
906        None
907    }
908
909    /// Render `text` wrapped in this style's ANSI codes.
910    pub fn render(&self, text: &str) -> String {
911        format!("{}{}{}", self.to_ansi(), text, self.reset_ansi())
912    }
913
914    /// Render a test/demo string. If text is None, use "Lorem ipsum".
915    pub fn test(&self, text: Option<&str>) -> String {
916        let t = text.unwrap_or("Lorem ipsum");
917        self.render(t)
918    }
919
920    /// Update or clear the link, returning self for chaining.
921    pub fn update_link(&mut self, url: Option<String>) -> &mut Self {
922        self.link = url;
923        if self.link.is_some() {
924            self.link_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
925        }
926        self
927    }
928
929    // -- accessors -------------------------------------------------------------
930
931    /// Get a reference to metadata.
932    pub fn meta(&self) -> Option<&Vec<u8>> {
933        self.meta.as_ref()
934    }
935
936    /// Get a mutable reference to metadata.
937    pub fn meta_mut(&mut self) -> Option<&mut Vec<u8>> {
938        self.meta.as_mut()
939    }
940
941    /// Set metadata, returning self for chaining.
942    pub fn set_meta(&mut self, meta: Option<Vec<u8>>) -> &mut Self {
943        self.meta = meta;
944        self
945    }
946
947    /// Get the link ID.
948    pub fn link_id(&self) -> u32 {
949        self.link_id
950    }
951
952    /// Alias for `bgcolor()` (Python rich has both `.on()` and `.bgcolor()`).
953    pub fn on(self, color: impl Into<Option<Color>>) -> Self {
954        self.bgcolor(color)
955    }
956
957    /// Get a reference to the foreground color.
958    pub fn color_ref(&self) -> Option<&Color> {
959        self.color.as_ref()
960    }
961
962    /// Get a reference to the background color.
963    pub fn bgcolor_ref(&self) -> Option<&Color> {
964        self.bgcolor.as_ref()
965    }
966}
967
968impl Default for Style {
969    fn default() -> Self {
970        Self::new()
971    }
972}
973
974impl PartialEq for Style {
975    fn eq(&self, other: &Self) -> bool {
976        self.color == other.color
977            && self.bgcolor == other.bgcolor
978            && self.attributes == other.attributes
979            && self.set_attributes == other.set_attributes
980            && self.link == other.link
981    }
982}
983
984impl Eq for Style {}
985
986impl Hash for Style {
987    fn hash<H: Hasher>(&self, state: &mut H) {
988        self.color.hash(state);
989        self.bgcolor.hash(state);
990        self.attributes.hash(state);
991        self.set_attributes.hash(state);
992        self.link.hash(state);
993    }
994}
995
996impl fmt::Display for Style {
997    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
998        if self.is_null {
999            return write!(f, "null");
1000        }
1001        let mut parts: Vec<String> = Vec::new();
1002        if let Some(ref c) = self.color {
1003            parts.push(c.to_string());
1004        }
1005        if let Some(ref c) = self.bgcolor {
1006            parts.push(format!("on {}", c));
1007        }
1008        let attrs = self.attributes.to_string();
1009        if attrs != "none" {
1010            parts.push(attrs);
1011        }
1012        if parts.is_empty() {
1013            write!(f, "none")
1014        } else {
1015            write!(f, "{}", parts.join(" "))
1016        }
1017    }
1018}
1019
1020/// Convenience type alias.
1021pub type StyleType = Style;
1022
1023// -- helper functions for html export and color name lookup ------------------
1024
1025/// Convert a `Color` to a CSS hex string `#rrggbb`.
1026fn color_to_css_hex(c: &Color) -> String {
1027    match c.color_type {
1028        ColorType::Default => String::new(),
1029        ColorType::Standard => {
1030            if let Some(n) = c.number {
1031                let (r, g, b) = STANDARD_PALETTE[n as usize];
1032                format!("#{:02x}{:02x}{:02x}", r, g, b)
1033            } else {
1034                String::new()
1035            }
1036        }
1037        ColorType::EightBit => {
1038            if let Some(n) = c.number {
1039                let [r, g, b] = EIGHT_BIT_PALETTE[n as usize];
1040                format!("#{:02x}{:02x}{:02x}", r, g, b)
1041            } else {
1042                String::new()
1043            }
1044        }
1045        ColorType::TrueColor => {
1046            if let Some((r, g, b)) = c.triplet {
1047                format!("#{:02x}{:02x}{:02x}", r, g, b)
1048            } else {
1049                String::new()
1050            }
1051        }
1052    }
1053}
1054
1055/// Return the static color name for a Standard color, or `None` otherwise.
1056fn color_to_name(c: &Color) -> Option<&'static str> {
1057    match c.color_type {
1058        ColorType::Standard => {
1059            if let Some(n) = c.number {
1060                Some(STANDARD_COLOR_NAMES[n as usize])
1061            } else {
1062                None
1063            }
1064        }
1065        _ => None,
1066    }
1067}
1068
1069// ---------------------------------------------------------------------------
1070// StyleStack — a stack of styles (for nested markup)
1071// ---------------------------------------------------------------------------
1072
1073/// A stack of styles, used when rendering nested markup.
1074///
1075/// Tracks tag names alongside styles to support proper close-tag matching
1076/// (e.g., `[/bold]` inside `[italic][bold]...` correctly pops to bold,
1077/// not just the top of the stack).
1078#[derive(Debug, Clone)]
1079pub struct StyleStack {
1080    stack: Vec<Style>,
1081    /// Tag names corresponding to each pushed style. Used by `pop_to()`
1082    /// to find and remove the matching opening tag.
1083    tag_names: Vec<String>,
1084    default_style: Style,
1085}
1086
1087impl StyleStack {
1088    /// Create a new style stack with a given default style.
1089    pub fn new(default_style: Style) -> Self {
1090        Self {
1091            stack: Vec::new(),
1092            tag_names: Vec::new(),
1093            default_style,
1094        }
1095    }
1096
1097    /// Get the current (combined) style.
1098    pub fn current(&self) -> Style {
1099        let mut combined = self.default_style.clone();
1100        for s in &self.stack {
1101            combined = combined.combine(s);
1102        }
1103        combined
1104    }
1105
1106    /// Push a style onto the stack (backward-compatible, no tag name).
1107    pub fn push(&mut self, style: Style) {
1108        self.tag_names.push(String::new());
1109        self.stack.push(style);
1110    }
1111
1112    /// Push a style with an associated tag name for close-tag matching.
1113    pub fn push_named(&mut self, name: String, style: Style) {
1114        self.tag_names.push(name);
1115        self.stack.push(style);
1116    }
1117
1118    /// Pop the top style (and its tag name).
1119    pub fn pop(&mut self) -> Option<Style> {
1120        self.tag_names.pop();
1121        self.stack.pop()
1122    }
1123
1124    /// Pop styles until the matching opening tag is found and removed.
1125    ///
1126    /// Searches from the top of the stack for a tag with the given name.
1127    /// If found, removes it and everything above it. If not found, pops
1128    /// just one style as a fallback.
1129    pub fn pop_to(&mut self, name: &str) {
1130        if let Some(pos) = self.tag_names.iter().rposition(|n| n == name) {
1131            self.stack.truncate(pos);
1132            self.tag_names.truncate(pos);
1133        } else {
1134            // Tag not found — pop one as fallback
1135            self.stack.pop();
1136            self.tag_names.pop();
1137        }
1138    }
1139
1140    /// Get the depth.
1141    pub fn len(&self) -> usize {
1142        self.stack.len()
1143    }
1144
1145    /// Returns `true` if the stack is empty (no pushed styles).
1146    pub fn is_empty(&self) -> bool {
1147        self.stack.is_empty()
1148    }
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153    use super::*;
1154
1155    #[test]
1156    fn test_style_parse() {
1157        let s = Style::from_str("bold red");
1158        assert_eq!(s.get_bold(), Some(true));
1159        assert!(s.color.is_some());
1160    }
1161
1162    #[test]
1163    fn test_style_combine() {
1164        let base = Style::from_str("red");
1165        let over = Style::from_str("bold");
1166        let combined = base.combine(&over);
1167        assert_eq!(combined.get_bold(), Some(true));
1168        assert!(combined.color.is_some());
1169    }
1170
1171    #[test]
1172    fn test_ansi_output() {
1173        let s = Style::new().color(Color::parse("red").unwrap()).bold(true);
1174        let ansi = s.to_ansi();
1175        assert!(ansi.contains("31")); // red foreground
1176        assert!(ansi.contains("1")); // bold
1177    }
1178
1179    #[test]
1180    fn test_chain() {
1181        let a = Style::new().bold(true);
1182        let b = Style::new()
1183            .color(Color::parse("red").unwrap())
1184            .italic(true);
1185        let chained = a.chain(&b);
1186        assert_eq!(chained.get_bold(), Some(true));
1187        assert!(chained.attributes.get(Attributes::ITALIC));
1188        assert!(chained.set_attributes & Attributes::ITALIC != 0);
1189        assert!(chained.color.is_some());
1190    }
1191
1192    #[test]
1193    fn test_chain_precedence() {
1194        let a = Style::new().bold(true).color(Color::parse("red").unwrap());
1195        let b = Style::new()
1196            .bold(false)
1197            .color(Color::parse("blue").unwrap());
1198        let chained = a.chain(&b);
1199        // a sets bold(true) and color(red); b sets bold(false) and color(blue)
1200        // chain: self's values take priority
1201        assert_eq!(chained.get_bold(), Some(true));
1202        let c = chained.color.as_ref().unwrap();
1203        let name = color_to_name(c);
1204        assert_eq!(name, Some("red"));
1205    }
1206
1207    #[test]
1208    fn test_copy() {
1209        let s = Style::new().bold(true).color(Color::parse("red").unwrap());
1210        let c = s.copy();
1211        assert_eq!(s, c);
1212    }
1213
1214    #[test]
1215    fn test_clear_meta_and_links() {
1216        let mut s = Style::new().link("https://example.com");
1217        s.meta = Some(vec![1, 2, 3]);
1218        s.clear_meta_and_links();
1219        assert!(s.link.is_none());
1220        assert!(s.meta.is_none());
1221    }
1222
1223    #[test]
1224    fn test_from_color() {
1225        let s = Style::from_color(Color::parse("red").unwrap());
1226        assert!(s.color.is_some());
1227        assert!(s.bgcolor.is_none());
1228    }
1229
1230    #[test]
1231    fn test_from_meta() {
1232        let s = Style::from_meta(vec![10, 20, 30]);
1233        assert_eq!(s.meta(), Some(&vec![10, 20, 30]));
1234    }
1235
1236    #[test]
1237    fn test_get_html_style() {
1238        let s = Style::new()
1239            .color(Color::parse("red").unwrap())
1240            .bold(true)
1241            .italic(true);
1242        let css = s.get_html_style(None);
1243        assert!(css.contains("color:"));
1244        assert!(css.contains("font-weight: bold"));
1245        assert!(css.contains("font-style: italic"));
1246    }
1247
1248    #[test]
1249    fn test_get_html_style_underline_strike() {
1250        let s = Style::new()
1251            .color(Color::parse("red").unwrap())
1252            .underline(true)
1253            .strike(true);
1254        let css = s.get_html_style(None);
1255        assert!(css.contains("text-decoration:"));
1256        assert!(css.contains("underline"));
1257        assert!(css.contains("line-through"));
1258    }
1259
1260    #[test]
1261    fn test_get_html_style_null() {
1262        let s = Style::null();
1263        let css = s.get_html_style(None);
1264        assert!(css.is_empty());
1265    }
1266
1267    #[test]
1268    fn test_normalize() {
1269        let s = Style::new().bold(true).italic(false);
1270        let n = s.normalize();
1271        assert_eq!(n.get_bold(), Some(true));
1272        // italic was set to false, so normalize should remove it
1273        assert!(n.set_attributes & Attributes::ITALIC == 0);
1274    }
1275
1276    #[test]
1277    fn test_pick_first() {
1278        let s = Style::new().color(Color::parse("red").unwrap());
1279        assert_eq!(s.pick_first(), Some("red"));
1280    }
1281
1282    #[test]
1283    fn test_pick_first_fallback() {
1284        let s = Style::new().bgcolor(Color::parse("blue").unwrap());
1285        assert_eq!(s.pick_first(), Some("blue"));
1286    }
1287
1288    #[test]
1289    fn test_pick_first_none() {
1290        let s = Style::new();
1291        assert_eq!(s.pick_first(), None);
1292    }
1293
1294    #[test]
1295    fn test_render() {
1296        let s = Style::new().bold(true).color(Color::parse("red").unwrap());
1297        let rendered = s.render("hello");
1298        assert!(rendered.starts_with("\x1b["));
1299        assert!(rendered.contains("hello"));
1300        assert!(rendered.ends_with("\x1b[0m"));
1301    }
1302
1303    #[test]
1304    fn test_test_with_text() {
1305        let s = Style::new().bold(true);
1306        let out = s.test(Some("custom"));
1307        assert!(out.contains("custom"));
1308    }
1309
1310    #[test]
1311    fn test_test_default() {
1312        let s = Style::new().bold(true);
1313        let out = s.test(None);
1314        assert!(out.contains("Lorem ipsum"));
1315    }
1316
1317    #[test]
1318    fn test_update_link() {
1319        let mut s = Style::new();
1320        s.update_link(Some("https://example.com".into()));
1321        assert!(s.link.is_some());
1322        let first_id = s.link_id;
1323        s.update_link(None);
1324        assert!(s.link.is_none());
1325        assert_eq!(s.link_id, first_id);
1326    }
1327
1328    #[test]
1329    fn test_link_id() {
1330        let s = Style::new().link("https://example.com");
1331        assert!(s.link_id() > 0);
1332    }
1333
1334    #[test]
1335    fn test_meta_methods() {
1336        let mut s = Style::new();
1337        s.set_meta(Some(vec![1, 2, 3]));
1338        assert_eq!(s.meta(), Some(&vec![1, 2, 3]));
1339        if let Some(m) = s.meta_mut() {
1340            m.push(4);
1341        }
1342        assert_eq!(s.meta(), Some(&vec![1, 2, 3, 4]));
1343    }
1344
1345    #[test]
1346    fn test_on() {
1347        let s = Style::new().on(Color::parse("red").unwrap());
1348        assert!(s.bgcolor.is_some());
1349        let b = Color::parse("red").unwrap();
1350        assert_eq!(s.bgcolor.unwrap(), b);
1351    }
1352
1353    #[test]
1354    fn test_references() {
1355        let s = Style::new()
1356            .color(Color::parse("red").unwrap())
1357            .bgcolor(Color::parse("blue").unwrap());
1358        assert!(s.color_ref().is_some());
1359        assert!(s.bgcolor_ref().is_some());
1360    }
1361
1362    #[test]
1363    fn test_color_to_css_hex() {
1364        let c = Color::parse("red").unwrap();
1365        let hex = color_to_css_hex(&c);
1366        assert_eq!(hex, "#800000"); // standard red
1367    }
1368
1369    #[test]
1370    fn test_color_to_css_hex_truecolor() {
1371        let c = Color::from_rgb(255, 0, 128);
1372        let hex = color_to_css_hex(&c);
1373        assert_eq!(hex, "#ff0080");
1374    }
1375
1376    #[test]
1377    fn test_null_style_constant() {
1378        assert!(NULL_STYLE.is_null());
1379        assert!(NULL_STYLE.color.is_none());
1380        assert!(NULL_STYLE.bgcolor.is_none());
1381        assert_eq!(NULL_STYLE.set_attributes, 0);
1382        // Style::null() should equal NULL_STYLE
1383        assert_eq!(Style::null(), NULL_STYLE);
1384    }
1385
1386    #[test]
1387    fn test_get_html_style_blink() {
1388        let s = Style::new().blink(true);
1389        let css = s.get_html_style(None);
1390        assert!(css.contains("blink"));
1391    }
1392
1393    #[test]
1394    fn test_get_html_style_reverse() {
1395        let s = Style::new().reverse(true);
1396        let css = s.get_html_style(None);
1397        assert!(css.contains("invert(100%)"));
1398    }
1399
1400    #[test]
1401    fn test_get_html_style_conceal() {
1402        let s = Style::new().conceal(true);
1403        let css = s.get_html_style(None);
1404        assert!(css.contains("visibility: hidden"));
1405    }
1406
1407    #[test]
1408    fn test_static_attributes() {
1409        assert!(!STYLE_ATTRIBUTES.is_empty());
1410        let names: Vec<&str> = STYLE_ATTRIBUTES.iter().map(|(n, _)| *n).collect();
1411        assert!(names.contains(&"bold"));
1412        assert!(names.contains(&"italic"));
1413        assert!(names.contains(&"underline"));
1414        assert!(!names.contains(&"notexist"));
1415    }
1416
1417    // -----------------------------------------------------------------------
1418    // "not" prefix / negation tests
1419    // -----------------------------------------------------------------------
1420
1421    #[test]
1422    fn test_not_bold_with_space() {
1423        // 'not' prefix negates the next token
1424        let s = Style::from_str("not bold");
1425        assert_eq!(s.get_bold(), Some(false));
1426    }
1427
1428    #[test]
1429    fn test_not_italic_with_space() {
1430        let s = Style::from_str("not italic");
1431        assert_eq!(s.get_italic(), Some(false));
1432    }
1433
1434    #[test]
1435    fn test_not_underline_with_space() {
1436        let s = Style::from_str("not underline");
1437        // Verify the bit is set and value is false (no get_underline accessor)
1438        assert!(s.set_attributes & Attributes::UNDERLINE != 0);
1439        assert!(!s.attributes.get(Attributes::UNDERLINE));
1440    }
1441
1442    #[test]
1443    fn test_bang_negation_bold() {
1444        let s = Style::from_str("!bold");
1445        assert_eq!(s.get_bold(), Some(false));
1446    }
1447
1448    #[test]
1449    fn test_nobold_prefix() {
1450        let s = Style::from_str("nobold");
1451        assert_eq!(s.get_bold(), Some(false));
1452    }
1453
1454    #[test]
1455    fn test_not_bold_red() {
1456        // "not bold red" should disable bold and set red color
1457        let s = Style::from_str("not bold red");
1458        assert_eq!(s.get_bold(), Some(false));
1459        assert!(s.color.is_some());
1460    }
1461
1462    #[test]
1463    fn test_not_multiple() {
1464        // "not bold not italic" should disable both
1465        let s = Style::from_str("not bold not italic");
1466        assert_eq!(s.get_bold(), Some(false));
1467        assert_eq!(s.get_italic(), Some(false));
1468    }
1469
1470    #[test]
1471    fn test_on_next_color() {
1472        // "on red" sets background color to red
1473        let s = Style::from_str("on red");
1474        assert!(s.bgcolor.is_some());
1475    }
1476}