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