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}