Skip to main content

ftui_style/
style.rs

1#![forbid(unsafe_code)]
2
3//! Style types for terminal UI styling with CSS-like cascading semantics.
4
5use ftui_render::cell::PackedRgba;
6use tracing::{instrument, trace};
7
8/// Text attribute flags (16 bits for extended attribute support).
9///
10/// These flags represent visual attributes that can be applied to text.
11/// Using u16 allows for additional underline variants beyond basic SGR.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
13#[repr(transparent)]
14pub struct StyleFlags(pub u16);
15
16impl StyleFlags {
17    /// No attributes set.
18    pub const NONE: Self = Self(0);
19    /// Bold / increased intensity.
20    pub const BOLD: Self = Self(1 << 0);
21    /// Dim / decreased intensity.
22    pub const DIM: Self = Self(1 << 1);
23    /// Italic text.
24    pub const ITALIC: Self = Self(1 << 2);
25    /// Single underline.
26    pub const UNDERLINE: Self = Self(1 << 3);
27    /// Blinking text.
28    pub const BLINK: Self = Self(1 << 4);
29    /// Reverse video (swap fg/bg).
30    pub const REVERSE: Self = Self(1 << 5);
31    /// Hidden / invisible text.
32    pub const HIDDEN: Self = Self(1 << 6);
33    /// Strikethrough text.
34    pub const STRIKETHROUGH: Self = Self(1 << 7);
35    /// Double underline (extended attribute).
36    pub const DOUBLE_UNDERLINE: Self = Self(1 << 8);
37    /// Curly / wavy underline (extended attribute).
38    pub const CURLY_UNDERLINE: Self = Self(1 << 9);
39
40    /// Check if this flags set contains another flags set.
41    #[inline]
42    pub const fn contains(self, other: Self) -> bool {
43        (self.0 & other.0) == other.0
44    }
45
46    /// Insert flags into this set.
47    #[inline]
48    pub fn insert(&mut self, other: Self) {
49        self.0 |= other.0;
50    }
51
52    /// Remove flags from this set.
53    #[inline]
54    pub fn remove(&mut self, other: Self) {
55        self.0 &= !other.0;
56    }
57
58    /// Check if the flags set is empty.
59    #[inline]
60    pub const fn is_empty(self) -> bool {
61        self.0 == 0
62    }
63
64    /// Combine two flag sets (OR operation).
65    #[inline]
66    pub const fn union(self, other: Self) -> Self {
67        Self(self.0 | other.0)
68    }
69}
70
71impl core::ops::BitOr for StyleFlags {
72    type Output = Self;
73
74    #[inline]
75    fn bitor(self, rhs: Self) -> Self::Output {
76        Self(self.0 | rhs.0)
77    }
78}
79
80impl core::ops::BitOrAssign for StyleFlags {
81    #[inline]
82    fn bitor_assign(&mut self, rhs: Self) {
83        self.0 |= rhs.0;
84    }
85}
86
87/// Unified styling type with CSS-like cascading semantics.
88///
89/// # Design Rationale
90/// - Option fields allow inheritance (None = inherit from parent)
91/// - Explicit masks track which properties are intentionally set
92/// - Copy + small size for cheap passing
93/// - Builder pattern for ergonomic construction
94///
95/// # Example
96/// ```
97/// use ftui_style::{Style, StyleFlags};
98/// use ftui_render::cell::PackedRgba;
99///
100/// let style = Style::new()
101///     .fg(PackedRgba::rgb(255, 0, 0))
102///     .bg(PackedRgba::rgb(0, 0, 0))
103///     .bold()
104///     .underline();
105/// ```
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
107pub struct Style {
108    /// Foreground color (text color).
109    pub fg: Option<PackedRgba>,
110    /// Background color.
111    pub bg: Option<PackedRgba>,
112    /// Text attributes (bold, italic, etc.).
113    pub attrs: Option<StyleFlags>,
114    /// Underline color (separate from fg for flexibility).
115    pub underline_color: Option<PackedRgba>,
116}
117
118impl Style {
119    /// Create an empty style (all properties inherit).
120    #[inline]
121    pub const fn new() -> Self {
122        Self {
123            fg: None,
124            bg: None,
125            attrs: None,
126            underline_color: None,
127        }
128    }
129
130    /// Set foreground color.
131    #[inline]
132    pub fn fg<C: Into<PackedRgba>>(mut self, color: C) -> Self {
133        self.fg = Some(color.into());
134        self
135    }
136
137    /// Set background color.
138    #[inline]
139    pub fn bg<C: Into<PackedRgba>>(mut self, color: C) -> Self {
140        self.bg = Some(color.into());
141        self
142    }
143
144    /// Add bold attribute.
145    #[inline]
146    pub fn bold(self) -> Self {
147        self.add_attr(StyleFlags::BOLD)
148    }
149
150    /// Add italic attribute.
151    #[inline]
152    pub fn italic(self) -> Self {
153        self.add_attr(StyleFlags::ITALIC)
154    }
155
156    /// Add underline attribute.
157    #[inline]
158    pub fn underline(self) -> Self {
159        self.add_attr(StyleFlags::UNDERLINE)
160    }
161
162    /// Add dim attribute.
163    #[inline]
164    pub fn dim(self) -> Self {
165        self.add_attr(StyleFlags::DIM)
166    }
167
168    /// Add reverse video attribute.
169    #[inline]
170    pub fn reverse(self) -> Self {
171        self.add_attr(StyleFlags::REVERSE)
172    }
173
174    /// Add strikethrough attribute.
175    #[inline]
176    pub fn strikethrough(self) -> Self {
177        self.add_attr(StyleFlags::STRIKETHROUGH)
178    }
179
180    /// Add blink attribute.
181    #[inline]
182    pub fn blink(self) -> Self {
183        self.add_attr(StyleFlags::BLINK)
184    }
185
186    /// Add hidden attribute.
187    #[inline]
188    pub fn hidden(self) -> Self {
189        self.add_attr(StyleFlags::HIDDEN)
190    }
191
192    /// Add double underline attribute.
193    #[inline]
194    pub fn double_underline(self) -> Self {
195        self.add_attr(StyleFlags::DOUBLE_UNDERLINE)
196    }
197
198    /// Add curly underline attribute.
199    #[inline]
200    pub fn curly_underline(self) -> Self {
201        self.add_attr(StyleFlags::CURLY_UNDERLINE)
202    }
203
204    /// Add an attribute flag.
205    #[inline]
206    fn add_attr(mut self, flag: StyleFlags) -> Self {
207        match &mut self.attrs {
208            Some(attrs) => attrs.insert(flag),
209            None => self.attrs = Some(flag),
210        }
211        self
212    }
213
214    /// Set underline color.
215    #[inline]
216    pub const fn underline_color(mut self, color: PackedRgba) -> Self {
217        self.underline_color = Some(color);
218        self
219    }
220
221    /// Set attributes directly.
222    #[inline]
223    pub const fn attrs(mut self, attrs: StyleFlags) -> Self {
224        self.attrs = Some(attrs);
225        self
226    }
227
228    /// Cascade merge: Fill in None fields from parent.
229    ///
230    /// `child.merge(parent)` returns a style where child's Some values
231    /// take precedence, and parent fills in any None values.
232    ///
233    /// For attributes, the flags are combined (OR operation) so both
234    /// parent and child attributes apply.
235    ///
236    /// # Example
237    /// ```
238    /// use ftui_style::Style;
239    /// use ftui_render::cell::PackedRgba;
240    ///
241    /// let parent = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
242    /// let child = Style::new().bg(PackedRgba::rgb(0, 0, 255));
243    /// let merged = child.merge(&parent);
244    /// // merged has: fg=RED (from parent), bg=BLUE (from child), bold (from parent)
245    /// ```
246    #[instrument(skip(self, parent), level = "trace")]
247    pub fn merge(&self, parent: &Style) -> Style {
248        trace!("Merging child style into parent");
249        Style {
250            fg: self.fg.or(parent.fg),
251            bg: self.bg.or(parent.bg),
252            attrs: match (self.attrs, parent.attrs) {
253                (Some(c), Some(p)) => Some(c.union(p)),
254                (Some(c), None) => Some(c),
255                (None, Some(p)) => Some(p),
256                (None, None) => None,
257            },
258            underline_color: self.underline_color.or(parent.underline_color),
259        }
260    }
261
262    /// Patch merge: Override parent with child's Some values.
263    ///
264    /// `parent.patch(&child)` returns a style where child's Some values
265    /// replace parent's values.
266    ///
267    /// This is the inverse perspective of merge().
268    #[inline]
269    pub fn patch(&self, child: &Style) -> Style {
270        child.merge(self)
271    }
272
273    /// Check if this style has any properties set.
274    #[inline]
275    pub const fn is_empty(&self) -> bool {
276        self.fg.is_none()
277            && self.bg.is_none()
278            && self.attrs.is_none()
279            && self.underline_color.is_none()
280    }
281
282    /// Check if a specific attribute is set.
283    #[inline]
284    pub fn has_attr(&self, flag: StyleFlags) -> bool {
285        self.attrs.is_some_and(|a| a.contains(flag))
286    }
287}
288
289/// Convert from cell-level StyleFlags (8-bit) to style-level StyleFlags (16-bit).
290impl From<ftui_render::cell::StyleFlags> for StyleFlags {
291    fn from(flags: ftui_render::cell::StyleFlags) -> Self {
292        let mut result = StyleFlags::NONE;
293        if flags.contains(ftui_render::cell::StyleFlags::BOLD) {
294            result.insert(StyleFlags::BOLD);
295        }
296        if flags.contains(ftui_render::cell::StyleFlags::DIM) {
297            result.insert(StyleFlags::DIM);
298        }
299        if flags.contains(ftui_render::cell::StyleFlags::ITALIC) {
300            result.insert(StyleFlags::ITALIC);
301        }
302        if flags.contains(ftui_render::cell::StyleFlags::UNDERLINE) {
303            result.insert(StyleFlags::UNDERLINE);
304        }
305        if flags.contains(ftui_render::cell::StyleFlags::BLINK) {
306            result.insert(StyleFlags::BLINK);
307        }
308        if flags.contains(ftui_render::cell::StyleFlags::REVERSE) {
309            result.insert(StyleFlags::REVERSE);
310        }
311        if flags.contains(ftui_render::cell::StyleFlags::STRIKETHROUGH) {
312            result.insert(StyleFlags::STRIKETHROUGH);
313        }
314        if flags.contains(ftui_render::cell::StyleFlags::HIDDEN) {
315            result.insert(StyleFlags::HIDDEN);
316        }
317        result
318    }
319}
320
321/// Convert from style-level StyleFlags (16-bit) to cell-level StyleFlags (8-bit).
322///
323/// Note: Extended flags (DOUBLE_UNDERLINE, CURLY_UNDERLINE) are mapped to
324/// basic UNDERLINE since the cell-level representation doesn't support them.
325impl From<StyleFlags> for ftui_render::cell::StyleFlags {
326    fn from(flags: StyleFlags) -> Self {
327        use ftui_render::cell::StyleFlags as CellFlags;
328        let mut result = CellFlags::empty();
329        if flags.contains(StyleFlags::BOLD) {
330            result |= CellFlags::BOLD;
331        }
332        if flags.contains(StyleFlags::DIM) {
333            result |= CellFlags::DIM;
334        }
335        if flags.contains(StyleFlags::ITALIC) {
336            result |= CellFlags::ITALIC;
337        }
338        // Map all underline variants to basic underline
339        if flags.contains(StyleFlags::UNDERLINE)
340            || flags.contains(StyleFlags::DOUBLE_UNDERLINE)
341            || flags.contains(StyleFlags::CURLY_UNDERLINE)
342        {
343            result |= CellFlags::UNDERLINE;
344        }
345        if flags.contains(StyleFlags::BLINK) {
346            result |= CellFlags::BLINK;
347        }
348        if flags.contains(StyleFlags::REVERSE) {
349            result |= CellFlags::REVERSE;
350        }
351        if flags.contains(StyleFlags::STRIKETHROUGH) {
352            result |= CellFlags::STRIKETHROUGH;
353        }
354        if flags.contains(StyleFlags::HIDDEN) {
355            result |= CellFlags::HIDDEN;
356        }
357        result
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_default_is_empty() {
367        let s = Style::default();
368        assert!(s.is_empty());
369        assert_eq!(s.fg, None);
370        assert_eq!(s.bg, None);
371        assert_eq!(s.attrs, None);
372        assert_eq!(s.underline_color, None);
373    }
374
375    #[test]
376    fn test_new_is_empty() {
377        let s = Style::new();
378        assert!(s.is_empty());
379    }
380
381    #[test]
382    fn test_builder_pattern_colors() {
383        let red = PackedRgba::rgb(255, 0, 0);
384        let black = PackedRgba::rgb(0, 0, 0);
385
386        let s = Style::new().fg(red).bg(black);
387
388        assert_eq!(s.fg, Some(red));
389        assert_eq!(s.bg, Some(black));
390        assert!(!s.is_empty());
391    }
392
393    #[test]
394    fn test_builder_pattern_attrs() {
395        let s = Style::new().bold().underline().italic();
396
397        assert!(s.has_attr(StyleFlags::BOLD));
398        assert!(s.has_attr(StyleFlags::UNDERLINE));
399        assert!(s.has_attr(StyleFlags::ITALIC));
400        assert!(!s.has_attr(StyleFlags::DIM));
401    }
402
403    #[test]
404    fn test_all_attribute_builders() {
405        let s = Style::new()
406            .bold()
407            .dim()
408            .italic()
409            .underline()
410            .blink()
411            .reverse()
412            .hidden()
413            .strikethrough()
414            .double_underline()
415            .curly_underline();
416
417        assert!(s.has_attr(StyleFlags::BOLD));
418        assert!(s.has_attr(StyleFlags::DIM));
419        assert!(s.has_attr(StyleFlags::ITALIC));
420        assert!(s.has_attr(StyleFlags::UNDERLINE));
421        assert!(s.has_attr(StyleFlags::BLINK));
422        assert!(s.has_attr(StyleFlags::REVERSE));
423        assert!(s.has_attr(StyleFlags::HIDDEN));
424        assert!(s.has_attr(StyleFlags::STRIKETHROUGH));
425        assert!(s.has_attr(StyleFlags::DOUBLE_UNDERLINE));
426        assert!(s.has_attr(StyleFlags::CURLY_UNDERLINE));
427    }
428
429    #[test]
430    fn test_merge_child_wins_on_conflict() {
431        let red = PackedRgba::rgb(255, 0, 0);
432        let blue = PackedRgba::rgb(0, 0, 255);
433
434        let parent = Style::new().fg(red);
435        let child = Style::new().fg(blue);
436        let merged = child.merge(&parent);
437
438        assert_eq!(merged.fg, Some(blue)); // Child wins
439    }
440
441    #[test]
442    fn test_merge_parent_fills_gaps() {
443        let red = PackedRgba::rgb(255, 0, 0);
444        let blue = PackedRgba::rgb(0, 0, 255);
445        let white = PackedRgba::rgb(255, 255, 255);
446
447        let parent = Style::new().fg(red).bg(white);
448        let child = Style::new().fg(blue); // No bg
449        let merged = child.merge(&parent);
450
451        assert_eq!(merged.fg, Some(blue)); // Child fg
452        assert_eq!(merged.bg, Some(white)); // Parent fills bg
453    }
454
455    #[test]
456    fn test_merge_attrs_combine() {
457        let parent = Style::new().bold();
458        let child = Style::new().italic();
459        let merged = child.merge(&parent);
460
461        assert!(merged.has_attr(StyleFlags::BOLD)); // From parent
462        assert!(merged.has_attr(StyleFlags::ITALIC)); // From child
463    }
464
465    #[test]
466    fn test_merge_with_empty_returns_self() {
467        let red = PackedRgba::rgb(255, 0, 0);
468        let style = Style::new().fg(red).bold();
469        let empty = Style::default();
470
471        let merged = style.merge(&empty);
472        assert_eq!(merged, style);
473    }
474
475    #[test]
476    fn test_empty_merge_with_parent() {
477        let red = PackedRgba::rgb(255, 0, 0);
478        let parent = Style::new().fg(red).bold();
479        let child = Style::default();
480
481        let merged = child.merge(&parent);
482        assert_eq!(merged, parent);
483    }
484
485    #[test]
486    fn test_patch_is_symmetric_with_merge() {
487        let red = PackedRgba::rgb(255, 0, 0);
488        let blue = PackedRgba::rgb(0, 0, 255);
489
490        let parent = Style::new().fg(red);
491        let child = Style::new().bg(blue);
492
493        let merged1 = child.merge(&parent);
494        let merged2 = parent.patch(&child);
495
496        assert_eq!(merged1, merged2);
497    }
498
499    #[test]
500    fn test_underline_color() {
501        let red = PackedRgba::rgb(255, 0, 0);
502        let s = Style::new().underline().underline_color(red);
503
504        assert!(s.has_attr(StyleFlags::UNDERLINE));
505        assert_eq!(s.underline_color, Some(red));
506    }
507
508    #[test]
509    fn test_style_flags_operations() {
510        let mut flags = StyleFlags::NONE;
511        assert!(flags.is_empty());
512
513        flags.insert(StyleFlags::BOLD);
514        flags.insert(StyleFlags::ITALIC);
515
516        assert!(flags.contains(StyleFlags::BOLD));
517        assert!(flags.contains(StyleFlags::ITALIC));
518        assert!(!flags.contains(StyleFlags::UNDERLINE));
519        assert!(!flags.is_empty());
520
521        flags.remove(StyleFlags::BOLD);
522        assert!(!flags.contains(StyleFlags::BOLD));
523        assert!(flags.contains(StyleFlags::ITALIC));
524    }
525
526    #[test]
527    fn test_style_flags_bitor() {
528        let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
529        assert!(flags.contains(StyleFlags::BOLD));
530        assert!(flags.contains(StyleFlags::ITALIC));
531    }
532
533    #[test]
534    fn test_style_flags_bitor_assign() {
535        let mut flags = StyleFlags::BOLD;
536        flags |= StyleFlags::ITALIC;
537        assert!(flags.contains(StyleFlags::BOLD));
538        assert!(flags.contains(StyleFlags::ITALIC));
539    }
540
541    #[test]
542    fn test_style_flags_union() {
543        let a = StyleFlags::BOLD;
544        let b = StyleFlags::ITALIC;
545        let c = a.union(b);
546        assert!(c.contains(StyleFlags::BOLD));
547        assert!(c.contains(StyleFlags::ITALIC));
548    }
549
550    #[test]
551    fn test_style_size() {
552        // Style should fit in a reasonable size
553        // 4 Option<PackedRgba> = 4 * 8 = 32 bytes (with Option overhead)
554        // + 1 Option<StyleFlags> = 4 bytes
555        // Total should be <= 40 bytes
556        assert!(
557            core::mem::size_of::<Style>() <= 40,
558            "Style is {} bytes, expected <= 40",
559            core::mem::size_of::<Style>()
560        );
561    }
562
563    #[test]
564    fn test_style_flags_size() {
565        assert_eq!(core::mem::size_of::<StyleFlags>(), 2);
566    }
567
568    #[test]
569    fn test_convert_to_cell_flags() {
570        let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
571        let cell_flags: ftui_render::cell::StyleFlags = flags.into();
572
573        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::BOLD));
574        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::ITALIC));
575        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::UNDERLINE));
576    }
577
578    #[test]
579    fn test_convert_to_cell_flags_all_basic() {
580        let flags = StyleFlags::BOLD
581            | StyleFlags::DIM
582            | StyleFlags::ITALIC
583            | StyleFlags::UNDERLINE
584            | StyleFlags::BLINK
585            | StyleFlags::REVERSE
586            | StyleFlags::STRIKETHROUGH
587            | StyleFlags::HIDDEN;
588        let cell_flags: ftui_render::cell::StyleFlags = flags.into();
589
590        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::BOLD));
591        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::DIM));
592        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::ITALIC));
593        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::UNDERLINE));
594        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::BLINK));
595        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::REVERSE));
596        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::STRIKETHROUGH));
597        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::HIDDEN));
598    }
599
600    #[test]
601    fn test_convert_from_cell_flags() {
602        use ftui_render::cell::StyleFlags as CellFlags;
603        let cell_flags = CellFlags::BOLD | CellFlags::ITALIC;
604        let style_flags: StyleFlags = cell_flags.into();
605
606        assert!(style_flags.contains(StyleFlags::BOLD));
607        assert!(style_flags.contains(StyleFlags::ITALIC));
608    }
609
610    #[test]
611    fn test_cell_flags_round_trip_preserves_basic_flags() {
612        use ftui_render::cell::StyleFlags as CellFlags;
613        let original = StyleFlags::BOLD
614            | StyleFlags::DIM
615            | StyleFlags::ITALIC
616            | StyleFlags::UNDERLINE
617            | StyleFlags::BLINK
618            | StyleFlags::REVERSE
619            | StyleFlags::STRIKETHROUGH
620            | StyleFlags::HIDDEN;
621        let cell_flags: CellFlags = original.into();
622        let round_trip: StyleFlags = cell_flags.into();
623
624        assert!(round_trip.contains(StyleFlags::BOLD));
625        assert!(round_trip.contains(StyleFlags::DIM));
626        assert!(round_trip.contains(StyleFlags::ITALIC));
627        assert!(round_trip.contains(StyleFlags::UNDERLINE));
628        assert!(round_trip.contains(StyleFlags::BLINK));
629        assert!(round_trip.contains(StyleFlags::REVERSE));
630        assert!(round_trip.contains(StyleFlags::STRIKETHROUGH));
631        assert!(round_trip.contains(StyleFlags::HIDDEN));
632    }
633
634    #[test]
635    fn test_extended_underline_maps_to_basic() {
636        let flags = StyleFlags::DOUBLE_UNDERLINE | StyleFlags::CURLY_UNDERLINE;
637        let cell_flags: ftui_render::cell::StyleFlags = flags.into();
638
639        // Extended underlines map to basic underline in cell representation
640        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::UNDERLINE));
641    }
642}
643
644#[cfg(test)]
645mod property_tests {
646    use super::*;
647    use proptest::prelude::*;
648
649    fn arb_packed_rgba() -> impl Strategy<Value = PackedRgba> {
650        any::<u32>().prop_map(PackedRgba)
651    }
652
653    fn arb_style_flags() -> impl Strategy<Value = StyleFlags> {
654        any::<u16>().prop_map(StyleFlags)
655    }
656
657    fn arb_style() -> impl Strategy<Value = Style> {
658        (
659            proptest::option::of(arb_packed_rgba()),
660            proptest::option::of(arb_packed_rgba()),
661            proptest::option::of(arb_style_flags()),
662            proptest::option::of(arb_packed_rgba()),
663        )
664            .prop_map(|(fg, bg, attrs, underline_color)| Style {
665                fg,
666                bg,
667                attrs,
668                underline_color,
669            })
670    }
671
672    proptest! {
673        #[test]
674        fn merge_with_empty_is_identity(s in arb_style()) {
675            let empty = Style::default();
676            prop_assert_eq!(s.merge(&empty), s);
677        }
678
679        #[test]
680        fn empty_merge_with_any_equals_any(parent in arb_style()) {
681            let empty = Style::default();
682            prop_assert_eq!(empty.merge(&parent), parent);
683        }
684
685        #[test]
686        fn merge_is_deterministic(a in arb_style(), b in arb_style()) {
687            let merged1 = a.merge(&b);
688            let merged2 = a.merge(&b);
689            prop_assert_eq!(merged1, merged2);
690        }
691
692        #[test]
693        fn patch_equals_reverse_merge(parent in arb_style(), child in arb_style()) {
694            let via_merge = child.merge(&parent);
695            let via_patch = parent.patch(&child);
696            prop_assert_eq!(via_merge, via_patch);
697        }
698
699        #[test]
700        fn style_flags_union_is_commutative(a in arb_style_flags(), b in arb_style_flags()) {
701            prop_assert_eq!(a.union(b), b.union(a));
702        }
703
704        #[test]
705        fn style_flags_union_is_associative(
706            a in arb_style_flags(),
707            b in arb_style_flags(),
708            c in arb_style_flags()
709        ) {
710            prop_assert_eq!(a.union(b).union(c), a.union(b.union(c)));
711        }
712    }
713}
714
715#[cfg(test)]
716mod merge_semantic_tests {
717    //! Tests for merge behavior and determinism.
718    //!
719    //! These tests verify the cascading semantics: child overrides parent
720    //! for colors, and flags combine for attributes.
721
722    use super::*;
723
724    #[test]
725    fn merge_chain_three_styles() {
726        // Test merging a chain: grandchild -> child -> parent
727        let red = PackedRgba::rgb(255, 0, 0);
728        let green = PackedRgba::rgb(0, 255, 0);
729        let blue = PackedRgba::rgb(0, 0, 255);
730        let white = PackedRgba::rgb(255, 255, 255);
731
732        let grandparent = Style::new().fg(red).bg(white).bold();
733        let parent = Style::new().fg(green).italic();
734        let child = Style::new().fg(blue);
735
736        // First merge: parent <- grandparent
737        let parent_merged = parent.merge(&grandparent);
738        assert_eq!(parent_merged.fg, Some(green)); // parent wins
739        assert_eq!(parent_merged.bg, Some(white)); // inherited from grandparent
740        assert!(parent_merged.has_attr(StyleFlags::BOLD)); // inherited
741        assert!(parent_merged.has_attr(StyleFlags::ITALIC)); // parent's
742
743        // Second merge: child <- parent_merged
744        let child_merged = child.merge(&parent_merged);
745        assert_eq!(child_merged.fg, Some(blue)); // child wins
746        assert_eq!(child_merged.bg, Some(white)); // inherited from grandparent
747        assert!(child_merged.has_attr(StyleFlags::BOLD)); // inherited
748        assert!(child_merged.has_attr(StyleFlags::ITALIC)); // inherited
749    }
750
751    #[test]
752    fn merge_chain_attrs_accumulate() {
753        // Attributes from all ancestors should accumulate
754        let s1 = Style::new().bold();
755        let s2 = Style::new().italic();
756        let s3 = Style::new().underline();
757
758        let merged = s3.merge(&s2.merge(&s1));
759
760        assert!(merged.has_attr(StyleFlags::BOLD));
761        assert!(merged.has_attr(StyleFlags::ITALIC));
762        assert!(merged.has_attr(StyleFlags::UNDERLINE));
763    }
764
765    #[test]
766    fn has_attr_returns_false_for_none() {
767        let style = Style::new(); // attrs is None
768        assert!(!style.has_attr(StyleFlags::BOLD));
769        assert!(!style.has_attr(StyleFlags::ITALIC));
770        assert!(!style.has_attr(StyleFlags::NONE));
771    }
772
773    #[test]
774    fn has_attr_returns_true_for_set_flags() {
775        let style = Style::new().bold().italic();
776        assert!(style.has_attr(StyleFlags::BOLD));
777        assert!(style.has_attr(StyleFlags::ITALIC));
778        assert!(!style.has_attr(StyleFlags::UNDERLINE));
779    }
780
781    #[test]
782    fn attrs_method_sets_directly() {
783        let flags = StyleFlags::BOLD | StyleFlags::DIM | StyleFlags::ITALIC;
784        let style = Style::new().attrs(flags);
785
786        assert_eq!(style.attrs, Some(flags));
787        assert!(style.has_attr(StyleFlags::BOLD));
788        assert!(style.has_attr(StyleFlags::DIM));
789        assert!(style.has_attr(StyleFlags::ITALIC));
790    }
791
792    #[test]
793    fn attrs_method_overwrites_previous() {
794        let style = Style::new().bold().italic().attrs(StyleFlags::UNDERLINE); // overwrites, doesn't combine
795
796        assert!(style.has_attr(StyleFlags::UNDERLINE));
797        // Bold and italic are NOT preserved when using attrs() directly
798        assert!(!style.has_attr(StyleFlags::BOLD));
799        assert!(!style.has_attr(StyleFlags::ITALIC));
800    }
801
802    #[test]
803    fn merge_preserves_explicit_transparent_color() {
804        // TRANSPARENT is a valid explicit color, should not be treated as "unset"
805        let transparent = PackedRgba::TRANSPARENT;
806        let red = PackedRgba::rgb(255, 0, 0);
807
808        let parent = Style::new().fg(red);
809        let child = Style::new().fg(transparent);
810
811        let merged = child.merge(&parent);
812        // Child explicitly sets transparent, should win over parent's red
813        assert_eq!(merged.fg, Some(transparent));
814    }
815
816    #[test]
817    fn merge_all_fields_independently() {
818        let parent = Style::new()
819            .fg(PackedRgba::rgb(1, 1, 1))
820            .bg(PackedRgba::rgb(2, 2, 2))
821            .underline_color(PackedRgba::rgb(3, 3, 3))
822            .bold();
823
824        let child = Style::new()
825            .fg(PackedRgba::rgb(10, 10, 10))
826            // no bg - should inherit
827            .underline_color(PackedRgba::rgb(30, 30, 30))
828            .italic();
829
830        let merged = child.merge(&parent);
831
832        // Child overrides fg
833        assert_eq!(merged.fg, Some(PackedRgba::rgb(10, 10, 10)));
834        // Parent fills bg
835        assert_eq!(merged.bg, Some(PackedRgba::rgb(2, 2, 2)));
836        // Child overrides underline_color
837        assert_eq!(merged.underline_color, Some(PackedRgba::rgb(30, 30, 30)));
838        // Both attrs combined
839        assert!(merged.has_attr(StyleFlags::BOLD));
840        assert!(merged.has_attr(StyleFlags::ITALIC));
841    }
842
843    #[test]
844    fn style_is_copy() {
845        let style = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
846        let copy = style; // Copy, not move
847        assert_eq!(style, copy);
848    }
849
850    #[test]
851    fn style_is_eq() {
852        let a = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
853        let b = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
854        let c = Style::new().fg(PackedRgba::rgb(0, 255, 0)).bold();
855
856        assert_eq!(a, b);
857        assert_ne!(a, c);
858    }
859
860    #[test]
861    fn style_is_hashable() {
862        use std::collections::HashSet;
863        let mut set = HashSet::new();
864
865        let a = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
866        let b = Style::new().fg(PackedRgba::rgb(0, 255, 0)).italic();
867
868        set.insert(a);
869        set.insert(b);
870        set.insert(a); // duplicate
871
872        assert_eq!(set.len(), 2);
873    }
874
875    #[test]
876    fn style_flags_contains_combined() {
877        let combined = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
878
879        // contains should return true for individual flags
880        assert!(combined.contains(StyleFlags::BOLD));
881        assert!(combined.contains(StyleFlags::ITALIC));
882        assert!(combined.contains(StyleFlags::UNDERLINE));
883
884        // contains should return true for subsets
885        assert!(combined.contains(StyleFlags::BOLD | StyleFlags::ITALIC));
886
887        // contains should return false for non-subset
888        assert!(!combined.contains(StyleFlags::DIM));
889        assert!(!combined.contains(StyleFlags::BOLD | StyleFlags::DIM));
890    }
891
892    #[test]
893    fn style_flags_none_is_identity_for_union() {
894        let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
895        assert_eq!(flags.union(StyleFlags::NONE), flags);
896        assert_eq!(StyleFlags::NONE.union(flags), flags);
897    }
898
899    #[test]
900    fn style_flags_remove_nonexistent_is_noop() {
901        let mut flags = StyleFlags::BOLD;
902        flags.remove(StyleFlags::ITALIC); // Not set, should be no-op
903        assert!(flags.contains(StyleFlags::BOLD));
904        assert!(!flags.contains(StyleFlags::ITALIC));
905    }
906}
907
908#[cfg(test)]
909mod performance_tests {
910    use super::*;
911
912    #[test]
913    fn test_style_merge_performance() {
914        let red = PackedRgba::rgb(255, 0, 0);
915        let blue = PackedRgba::rgb(0, 0, 255);
916
917        let parent = Style::new().fg(red).bold();
918        let child = Style::new().bg(blue).italic();
919
920        let start = std::time::Instant::now();
921        for _ in 0..1_000_000 {
922            let _ = std::hint::black_box(child.merge(&parent));
923        }
924        let elapsed = start.elapsed();
925
926        // 1M merges should be < 100ms (< 100ns each)
927        // Being generous with threshold for CI variability
928        assert!(
929            elapsed.as_millis() < 100,
930            "Merge too slow: {:?} for 1M iterations",
931            elapsed
932        );
933    }
934}