Skip to main content

uncurses/style/
mod.rs

1//! Text style values and terminal SGR/OSC 8 rendering.
2//!
3//! ## Style as a value
4//!
5//! [`Style`] is an owned description of how text should look: optional
6//! foreground/background colors, optional underline color, an
7//! [`UnderlineStyle`], an [`AttrFlags`] bitset, and an optional OSC 8
8//! [`Link`]. Builder methods take and return `Self`, so styles can be composed
9//! fluently and then cloned into cells or spans.
10//!
11//! ## Open/close versus wrapped rendering
12//!
13//! The [`std::fmt::Display`] implementation for [`Style`] emits the opener: the
14//! SGR sequence (`CSI … m`), followed by the OSC 8 hyperlink start when the
15//! style carries a link. The opener does not reset the terminal or close the
16//! link afterward; following output remains in that style until another style
17//! or reset is written.
18//!
19//! The alternate form (`{style:#}`) emits the matching closer: the OSC 8
20//! hyperlink terminator (when the style carries a link) followed by the SGR
21//! reset (`CSI m`). Used together, `{style}` and `{style:#}` wrap a complete
22//! span with a single style value. The SGR reset clears to defaults rather than
23//! restoring a previously active style, so wrap each span independently:
24//!
25//! ```text
26//! without link: ┌─────────┐ ┌──────┐ ┌───────┐
27//!               │ CSI … m │▶│ text │▶│ CSI m │
28//!               └─────────┘ └──────┘ └───────┘
29//! with link:    ┌─────────┐ ┌───────┐ ┌──────┐ ┌───────────┐ ┌───────┐
30//!               │ CSI … m │▶│ OSC 8 │▶│ text │▶│ OSC 8 end │▶│ CSI m │
31//!               └─────────┘ └───────┘ └──────┘ └───────────┘ └───────┘
32//! ```
33//!
34//! ## Attributes and underline
35//!
36//! Boolean SGR attributes such as bold, italic, blinking, reverse video,
37//! conceal, and strikethrough live in [`AttrFlags`]. Underlining is modeled
38//! separately with [`UnderlineStyle`] because SGR supports multiple underline
39//! shapes (`4`, `4:2`, `4:3`, `4:4`, `4:5`) and an independent underline
40//! color.
41//!
42//! ## SGR encoding
43//!
44//! Style emission uses a single `CSI … m` sequence for all SGR state. Standard
45//! foreground/background colors use `30`–`37`/`40`–`47`, bright colors use
46//! `90`–`97`/`100`–`107`, indexed colors use `38;5;n`/`48;5;n`, true color
47//! uses `38;2;r;g;b`/`48;2;r;g;b`, and underline color uses the colon
48//! subparameter form (`58:5:n` or `58:2::r:g:b`).
49//!
50//! ```text
51//! ESC [   1 ;   4:3  ;    38;2;255;128;0  ; 58:2::0:255:255     m
52//! └─┬─┘ └─────────────── SGR parameters ─────────────────────┘ └┬┘
53//!  CSI  attrs  underline   fg truecolor     ul color          final
54//! ```
55//!
56//! ```rust
57//! use uncurses::color::Color;
58//! use uncurses::style::Style;
59//!
60//! // `{style}` writes the opener; `{style:#}` writes the matching closer.
61//! let heading = Style::default().bold().fg(Color::Green);
62//! println!("{heading}Hello{heading:#}");
63//!
64//! let link = Style::default()
65//!     .underline()
66//!     .link("https://example.com", "");
67//! println!("{link}docs{link:#}");
68//! ```
69
70pub(crate) mod diff;
71mod parse;
72mod sgr;
73
74pub(crate) use parse::read_style;
75#[cfg(test)]
76pub(crate) use sgr::RESET;
77
78use std::borrow::Borrow;
79use std::io::{self, Write};
80use std::sync::Arc;
81
82use bitflags::bitflags;
83
84use crate::color::Color;
85
86/// OSC 8 hyperlink target carried by a [`Style`].
87///
88/// A link is used when styled text should also open a terminal hyperlink.
89/// [`Style::link`] stores non-empty URLs behind an [`Arc`] so many cells in
90/// the same hyperlink span can share one allocation. The URL and parameter
91/// string are emitted verbatim as `OSC 8 ; params ; url ST`; callers are
92/// responsible for passing values that are appropriate for the target
93/// terminal.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub struct Link {
96    /// Target URI written into the OSC 8 sequence.
97    ///
98    /// Empty URLs are not stored by [`Style::link`]; passing an empty URL to
99    /// that builder clears the current link instead.
100    pub url: String,
101    /// OSC 8 parameter string written between the two semicolons.
102    ///
103    /// Use an empty string for no parameters, or terminal-supported
104    /// `key=value` pairs such as `id=section`.
105    pub params: String,
106}
107
108bitflags! {
109    /// Bitflags for SGR text attributes.
110    ///
111    /// These are the boolean attributes that can be combined freely on a
112    /// [`Style`]. Underline shape is tracked separately by
113    /// [`UnderlineStyle`], and colors are stored as [`Color`](crate::color::Color).
114    /// Use [`Style::attrs`] when replacing the whole set, or the convenience
115    /// builders such as [`Style::bold`] and [`Style::italic`] when adding one
116    /// flag at a time.
117    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
118    pub struct AttrFlags: u16 {
119        /// Bold/intense text (`SGR 1`).
120        ///
121        /// Cleared together with [`FAINT`](Self::FAINT) by `SGR 22`.
122        const BOLD          = 0b0000_0000_0001;
123        /// Faint/decreased-intensity text (`SGR 2`).
124        ///
125        /// Cleared together with [`BOLD`](Self::BOLD) by `SGR 22`.
126        const FAINT         = 0b0000_0000_0010;
127        /// Italic text (`SGR 3`, cleared by `SGR 23`).
128        const ITALIC        = 0b0000_0000_0100;
129        /// Slow blinking text (`SGR 5`).
130        ///
131        /// Cleared together with [`RAPID_BLINK`](Self::RAPID_BLINK) by
132        /// `SGR 25`.
133        const SLOW_BLINK    = 0b0000_0000_1000;
134        /// Rapid blinking text (`SGR 6`).
135        ///
136        /// Cleared together with [`SLOW_BLINK`](Self::SLOW_BLINK) by
137        /// `SGR 25`.
138        const RAPID_BLINK   = 0b0000_0001_0000;
139        /// Reverse foreground and background (`SGR 7`, cleared by `SGR 27`).
140        const REVERSE       = 0b0000_0010_0000;
141        /// Concealed text (`SGR 8`, cleared by `SGR 28`).
142        const CONCEAL       = 0b0000_0100_0000;
143        /// Struck-through text (`SGR 9`, cleared by `SGR 29`).
144        const STRIKETHROUGH = 0b0000_1000_0000;
145    }
146}
147
148/// Underline shape encoded in SGR underline parameters.
149///
150/// Use [`Style::underline`] for the common single underline or
151/// [`Style::underline_style`] to select an explicit shape. [`None`](Self::None)
152/// means no underline; it emits no parameter when writing a full [`Style`] and
153/// emits `SGR 24` when a diff needs to clear an existing underline.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
155#[repr(u8)]
156pub enum UnderlineStyle {
157    #[default]
158    /// No underline.
159    None = 0,
160    /// Single underline (`SGR 4` or `4:1` when parsed).
161    Single = 1,
162    /// Double underline (`SGR 4:2`; `SGR 21` is parsed as this variant).
163    Double = 2,
164    /// Curly underline (`SGR 4:3`).
165    Curly = 3,
166    /// Dotted underline (`SGR 4:4`).
167    Dotted = 4,
168    /// Dashed underline (`SGR 4:5`).
169    Dashed = 5,
170}
171
172/// A complete terminal text style.
173///
174/// `Style` is the central value used by cells, text painters, and renderers.
175/// It stores SGR state (foreground/background/underline colors, underline
176/// shape, and attributes) plus an optional OSC 8 hyperlink. Build styles with
177/// the provided value-taking builders, then render with the
178/// [`std::fmt::Display`] implementation: `{style}` writes the opener and
179/// `{style:#}` writes the matching closer.
180///
181/// Cloning is cheap for hyperlinks: the [`Link`] is reference-counted so a
182/// long span of identically-linked cells keeps a single shared allocation.
183#[derive(Debug, Clone, Default)]
184pub struct Style {
185    /// Foreground/text color.
186    ///
187    /// `None` leaves the terminal's current/default foreground unchanged when
188    /// writing a full style; diffs use `SGR 39` to clear a previous foreground.
189    pub fg: Option<Color>,
190    /// Background color.
191    ///
192    /// `None` leaves the terminal's current/default background unchanged when
193    /// writing a full style; diffs use `SGR 49` to clear a previous background.
194    pub bg: Option<Color>,
195    /// Underline color.
196    ///
197    /// Encoded with SGR `58` when present. `None` means use the terminal's
198    /// default underline color; diffs use `SGR 59` to clear a previous value.
199    pub underline_color: Option<Color>,
200    /// Underline shape.
201    pub underline: UnderlineStyle,
202    /// Boolean SGR text attributes.
203    pub attrs: AttrFlags,
204    /// Optional OSC 8 hyperlink target.
205    pub link: Option<Arc<Link>>,
206}
207
208impl PartialEq for Style {
209    fn eq(&self, other: &Self) -> bool {
210        self.fg == other.fg
211            && self.bg == other.bg
212            && self.underline_color == other.underline_color
213            && self.underline == other.underline
214            && self.attrs == other.attrs
215            && match (&self.link, &other.link) {
216                (None, None) => true,
217                (Some(a), Some(b)) => Arc::ptr_eq(a, b) || **a == **b,
218                _ => false,
219            }
220    }
221}
222
223impl Eq for Style {}
224
225impl std::hash::Hash for Style {
226    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
227        self.fg.hash(state);
228        self.bg.hash(state);
229        self.underline_color.hash(state);
230        self.underline.hash(state);
231        self.attrs.hash(state);
232        if let Some(l) = &self.link {
233            l.hash(state);
234        }
235    }
236}
237
238impl From<&Style> for Style {
239    /// Clone a borrowed style into an owned one.
240    ///
241    /// This lets APIs that accept `impl Into<Style>` take a `&Style` without
242    /// the caller writing `.clone()`. Owned styles convert for free via the
243    /// blanket `From<Style> for Style`.
244    fn from(style: &Style) -> Self {
245        style.clone()
246    }
247}
248
249impl From<Option<Style>> for Style {
250    /// Convert an optional style, mapping `None` to [`Style::EMPTY`].
251    ///
252    /// This lets APIs that accept `impl Into<Style>` take a bare `None` to mean
253    /// "no styling", e.g. `surface.set_str(pos, text, None)`. `Some(style)`
254    /// unwraps to the contained style.
255    fn from(style: Option<Style>) -> Self {
256        style.unwrap_or(Style::EMPTY)
257    }
258}
259
260impl Style {
261    /// Create an empty style.
262    ///
263    /// Equivalent to [`Style::default`] and [`Style::EMPTY`]: no colors,
264    /// attributes, underline, or hyperlink. Chain the builder methods such as
265    /// [`bold`](Self::bold) or [`fg`](Self::fg) to add settings.
266    pub const fn new() -> Self {
267        Self::EMPTY
268    }
269
270    /// Style with no colors, attributes, underline, or hyperlink.
271    ///
272    /// This is equivalent to [`Style::default()`]. Writing it as a style opener
273    /// emits nothing, since the opener is additive; format any style with the
274    /// alternate flag (`{style:#}`) to emit an explicit return to the terminal
275    /// default.
276    pub const EMPTY: Style = Style {
277        fg: None,
278        bg: None,
279        underline_color: None,
280        underline: UnderlineStyle::None,
281        attrs: AttrFlags::empty(),
282        link: None,
283    };
284
285    /// Return whether this style is entirely empty.
286    ///
287    /// Empty means no SGR-relevant fields and no OSC 8 hyperlink. This is
288    /// equivalent to `*self == Style::EMPTY`.
289    pub fn is_empty(&self) -> bool {
290        self.is_sgr_empty() && self.is_link_empty()
291    }
292
293    /// Return whether this style has no SGR-relevant settings.
294    ///
295    /// Colors, attributes, underline shape, and underline color are checked.
296    /// The hyperlink is intentionally ignored because it is emitted via OSC 8,
297    /// not SGR.
298    pub(crate) fn is_sgr_empty(&self) -> bool {
299        self.fg.is_none()
300            && self.bg.is_none()
301            && self.underline_color.is_none()
302            && self.underline == UnderlineStyle::None
303            && self.attrs.is_empty()
304    }
305
306    /// Return whether this style carries no hyperlink.
307    ///
308    /// Companion to [`Style::is_sgr_empty`]; together they decide whether the
309    /// style value has any terminal-visible state.
310    pub(crate) fn is_link_empty(&self) -> bool {
311        self.link.is_none()
312    }
313
314    /// Inherit from `base`, returning `self` with its unset fields filled in.
315    ///
316    /// `self` takes precedence, like a child overriding inherited values: its
317    /// foreground, background, underline color, underline shape, and hyperlink
318    /// win wherever `self` sets them, and `base` only supplies a fallback for
319    /// each field `self` leaves at its default. Attributes from both are
320    /// combined. Inheriting from any `base` therefore never clears a field
321    /// `self` already set, and inheriting from an empty `base` returns `self`
322    /// unchanged.
323    ///
324    /// `base` is borrowed, so either an owned [`Style`] or a `&Style` may be
325    /// passed without cloning.
326    pub fn inherit(&self, base: impl Borrow<Style>) -> Style {
327        let base = base.borrow();
328        Style {
329            fg: self.fg.or(base.fg),
330            bg: self.bg.or(base.bg),
331            underline_color: self.underline_color.or(base.underline_color),
332            underline: if self.underline == UnderlineStyle::None {
333                base.underline
334            } else {
335                self.underline
336            },
337            attrs: self.attrs | base.attrs,
338            link: self.link.clone().or_else(|| base.link.clone()),
339        }
340    }
341
342    /// Add bold intensity and return the updated style.
343    ///
344    /// This sets [`AttrFlags::BOLD`] and leaves all other fields unchanged.
345    pub fn bold(mut self) -> Self {
346        self.attrs |= AttrFlags::BOLD;
347        self
348    }
349
350    /// Add faint intensity and return the updated style.
351    ///
352    /// This sets [`AttrFlags::FAINT`] and leaves all other fields unchanged.
353    pub fn faint(mut self) -> Self {
354        self.attrs |= AttrFlags::FAINT;
355        self
356    }
357
358    /// Add italic text and return the updated style.
359    ///
360    /// This sets [`AttrFlags::ITALIC`] and leaves all other fields unchanged.
361    pub fn italic(mut self) -> Self {
362        self.attrs |= AttrFlags::ITALIC;
363        self
364    }
365
366    /// Use a single underline and return the updated style.
367    ///
368    /// Equivalent to [`Style::underline_style`] with
369    /// [`UnderlineStyle::Single`].
370    pub fn underline(mut self) -> Self {
371        self.underline = UnderlineStyle::Single;
372        self
373    }
374
375    /// Add strikethrough text and return the updated style.
376    ///
377    /// This sets [`AttrFlags::STRIKETHROUGH`] and leaves all other fields
378    /// unchanged.
379    pub fn strikethrough(mut self) -> Self {
380        self.attrs |= AttrFlags::STRIKETHROUGH;
381        self
382    }
383
384    /// Add slow blinking text and return the updated style.
385    ///
386    /// This sets [`AttrFlags::SLOW_BLINK`] and leaves all other fields
387    /// unchanged.
388    pub fn blink(mut self) -> Self {
389        self.attrs |= AttrFlags::SLOW_BLINK;
390        self
391    }
392
393    /// Add rapid blinking text and return the updated style.
394    ///
395    /// This sets [`AttrFlags::RAPID_BLINK`] and leaves all other fields
396    /// unchanged.
397    pub fn rapid_blink(mut self) -> Self {
398        self.attrs |= AttrFlags::RAPID_BLINK;
399        self
400    }
401
402    /// Reverse foreground and background and return the updated style.
403    ///
404    /// This sets [`AttrFlags::REVERSE`]; it does not swap the stored `fg` and
405    /// `bg` values.
406    pub fn reverse(mut self) -> Self {
407        self.attrs |= AttrFlags::REVERSE;
408        self
409    }
410
411    /// Add concealed text and return the updated style.
412    ///
413    /// This sets [`AttrFlags::CONCEAL`] and leaves all other fields unchanged.
414    pub fn conceal(mut self) -> Self {
415        self.attrs |= AttrFlags::CONCEAL;
416        self
417    }
418
419    /// Set or clear the foreground color and return the updated style.
420    ///
421    /// Accepts any value convertible into `Option<Color>`, including a
422    /// [`Color`] or `None`.
423    /// Passing `None` clears any foreground color carried by the base style.
424    pub fn fg(mut self, color: impl Into<Option<Color>>) -> Self {
425        self.fg = color.into();
426        self
427    }
428
429    /// Set or clear the background color and return the updated style.
430    ///
431    /// Accepts any value convertible into `Option<Color>`, including a
432    /// [`Color`] or `None`.
433    /// Passing `None` clears any background color carried by the base style.
434    pub fn bg(mut self, color: impl Into<Option<Color>>) -> Self {
435        self.bg = color.into();
436        self
437    }
438
439    /// Set or clear the underline color and return the updated style.
440    ///
441    /// Accepts any value convertible into `Option<Color>`, including a
442    /// [`Color`] or `None`.
443    /// Passing `None` clears any underline color carried by the base style.
444    pub fn underline_color(mut self, color: impl Into<Option<Color>>) -> Self {
445        self.underline_color = color.into();
446        self
447    }
448
449    /// Set the underline shape and return the updated style.
450    ///
451    /// Use [`UnderlineStyle::None`] to clear underlining.
452    pub fn underline_style(mut self, style: UnderlineStyle) -> Self {
453        self.underline = style;
454        self
455    }
456
457    /// Replace the entire attribute flag set and return the updated style.
458    ///
459    /// This is useful when applying a previously computed [`AttrFlags`] value.
460    /// Use the convenience builders when adding a single flag.
461    pub fn attrs(mut self, attrs: AttrFlags) -> Self {
462        self.attrs = attrs;
463        self
464    }
465
466    /// Attach or clear an OSC 8 hyperlink and return the updated style.
467    ///
468    /// A non-empty `url` stores a [`Link`] with the supplied `params`. An empty
469    /// `url` clears any existing link and ignores `params`. Parameters are the
470    /// raw OSC 8 parameter string, commonly empty or a terminal-supported value
471    /// such as `id=foo`.
472    pub fn link(mut self, url: impl Into<String>, params: impl Into<String>) -> Self {
473        let url = url.into();
474        if url.is_empty() {
475            self.link = None;
476        } else {
477            self.link = Some(Arc::new(Link {
478                url,
479                params: params.into(),
480            }));
481        }
482        self
483    }
484
485    /// Write this style's opener bytes: the SGR sequence (`CSI … m`) followed
486    /// by an OSC 8 hyperlink start when this style carries a [`link`](Self::link).
487    ///
488    /// Additive: an [empty](Self::is_empty) style writes nothing.
489    fn write_opener<W: Write>(&self, w: &mut W) -> io::Result<()> {
490        sgr::write_style(w, self)?;
491        if let Some(link) = &self.link {
492            crate::ansi::hyperlink::write_hyperlink(w, &link.url, &link.params)?;
493        }
494        Ok(())
495    }
496
497    /// Write this style's closer bytes: the OSC 8 hyperlink terminator when this
498    /// style carries a [`link`](Self::link), followed by the SGR reset (`CSI m`)
499    /// when it carries any SGR state.
500    ///
501    /// The SGR reset clears all attributes to their defaults, so the closer
502    /// returns the terminal to a clean state rather than restoring whatever
503    /// style was active before the opener. An [empty](Self::is_empty) style
504    /// writes nothing.
505    fn write_closer<W: Write>(&self, w: &mut W) -> io::Result<()> {
506        if self.link.is_some() {
507            w.write_all(crate::ansi::hyperlink::HYPERLINK_RESET)?;
508        }
509        if !self.is_sgr_empty() {
510            w.write_all(sgr::RESET)?;
511        }
512        Ok(())
513    }
514}
515
516/// Render this style as ANSI escape sequences.
517///
518/// The default form (`{style}`) renders the **opener**: the SGR sequence
519/// (`CSI … m`) followed by an OSC 8 hyperlink start when this style carries a
520/// [`link`](Style::link). The opener is additive, so an empty style renders
521/// nothing.
522///
523/// The alternate form (`{style:#}`) renders the **closer**: the OSC 8
524/// hyperlink terminator (when the style carries a link) followed by the SGR
525/// reset (`CSI m`, when it carries SGR state). The SGR reset clears all
526/// attributes to their defaults rather than restoring a previously active
527/// style.
528///
529/// Use the two together to wrap a span with a single style value:
530///
531/// ```
532/// use uncurses::color::Color;
533/// use uncurses::style::Style;
534///
535/// let style = Style::default().bold().fg(Color::Green);
536/// assert_eq!(format!("{style}hi{style:#}"), "\x1b[1;32mhi\x1b[m");
537/// ```
538impl std::fmt::Display for Style {
539    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
540        // SGR sequences and OSC 8 framing are pure ASCII, so the bytes are
541        // valid UTF-8.
542        let mut buf = Vec::new();
543        if f.alternate() {
544            self.write_closer(&mut buf).map_err(|_| std::fmt::Error)?;
545        } else {
546            self.write_opener(&mut buf).map_err(|_| std::fmt::Error)?;
547        }
548        let s = std::str::from_utf8(&buf).map_err(|_| std::fmt::Error)?;
549        f.write_str(s)
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::color::Color;
557
558    #[test]
559    fn test_style_empty() {
560        assert!(Style::EMPTY.is_empty());
561        assert!(Style::default().is_empty());
562    }
563
564    #[test]
565    fn test_style_builder() {
566        let s = Style::EMPTY.bold().italic().fg(Color::Red);
567        assert!(s.attrs.contains(AttrFlags::BOLD));
568        assert!(s.attrs.contains(AttrFlags::ITALIC));
569        assert_eq!(s.fg, Some(Color::Red));
570        assert!(!s.is_empty());
571    }
572
573    #[test]
574    fn link_empty_url_clears() {
575        let s = Style::EMPTY.link("https://x", "id=42");
576        let l = s.link.as_deref().unwrap();
577        assert_eq!((l.url.as_str(), l.params.as_str()), ("https://x", "id=42"));
578
579        // Empty url clears, regardless of params.
580        let s = s.link("", "id=ignored");
581        assert!(s.link.is_none());
582        assert!(s.is_empty());
583
584        let s = Style::EMPTY.link("", "");
585        assert!(s.link.is_none());
586    }
587
588    #[test]
589    fn display_emits_sgr_opener_without_reset() {
590        let s = Style::EMPTY.bold().fg(Color::Red);
591        assert_eq!(format!("{s}"), "\x1b[1;31m");
592    }
593
594    #[test]
595    fn display_empty_style_emits_nothing() {
596        assert_eq!(format!("{}", Style::EMPTY), "");
597    }
598
599    #[test]
600    fn span_wraps_text_in_style_and_reset() {
601        let s = Style::EMPTY.bold();
602        // SGR-only style: opener, text, then just the SGR reset (no link close).
603        assert_eq!(format!("{s}hi{s:#}"), "\x1b[1mhi\x1b[m");
604    }
605
606    #[test]
607    fn alternate_display_closes_only_what_is_set() {
608        // SGR-only style closes with the SGR reset alone.
609        let s = Style::EMPTY.bold();
610        assert_eq!(format!("{s:#}"), "\x1b[m");
611
612        // A link adds the OSC 8 terminator before the SGR reset.
613        let s = Style::EMPTY.bold().link("https://x", "");
614        assert_eq!(format!("{s:#}"), "\x1b]8;;\x1b\\\x1b[m");
615
616        // A link-only style emits just the OSC 8 terminator, no SGR reset.
617        let s = Style::EMPTY.link("https://x", "");
618        assert_eq!(format!("{s:#}"), "\x1b]8;;\x1b\\");
619
620        // An empty style resets nothing.
621        assert_eq!(format!("{:#}", Style::EMPTY), "");
622    }
623
624    #[test]
625    fn span_empty_style_emits_text_only() {
626        let s = Style::EMPTY;
627        assert_eq!(format!("{s}hi{s:#}"), "hi");
628    }
629
630    #[test]
631    fn inherit_self_wins_and_fills_unset_from_base() {
632        let style = Style::EMPTY.italic().fg(Color::Blue);
633        let base = Style::EMPTY.bold().fg(Color::Red).bg(Color::Black);
634        let merged = style.inherit(base);
635        // self's fg wins; bg is inherited from base; attributes combine.
636        assert_eq!(merged.fg, Some(crate::color::Color::Blue));
637        assert_eq!(merged.bg, Some(crate::color::Color::Black));
638        assert!(merged.attrs.contains(AttrFlags::BOLD));
639        assert!(merged.attrs.contains(AttrFlags::ITALIC));
640    }
641
642    #[test]
643    fn inherit_empty_self_returns_base() {
644        let base = Style::EMPTY.bold().fg(Color::Red);
645        // An empty child inherits everything from the base.
646        assert_eq!(Style::EMPTY.inherit(&base), base);
647    }
648
649    #[test]
650    fn inherit_empty_base_keeps_self() {
651        let style = Style::EMPTY.bold().fg(Color::Red);
652        let merged = style.inherit(Style::EMPTY);
653        assert_eq!(merged, style);
654    }
655
656    #[test]
657    fn span_wraps_link_in_osc8() {
658        let s = Style::EMPTY.underline().link("https://example.com", "");
659        // SGR opener, hyperlink start, text, hyperlink end, SGR reset.
660        assert_eq!(
661            format!("{s}docs{s:#}"),
662            "\x1b[4m\x1b]8;;https://example.com\x1b\\docs\x1b]8;;\x1b\\\x1b[m"
663        );
664    }
665
666    #[test]
667    fn opener_includes_hyperlink_after_sgr() {
668        let s = Style::EMPTY.underline().link("https://example.com", "");
669        // The opener is the SGR sequence followed by the OSC 8 hyperlink start.
670        assert_eq!(format!("{s}"), "\x1b[4m\x1b]8;;https://example.com\x1b\\");
671    }
672}