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/// CSS-like text-transform property.
9///
10/// Terminal backends apply this at render time by transforming the
11/// text content before measuring cell widths. Transformations are
12/// locale-independent (ASCII-only) for deterministic output.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
16pub enum TextTransform {
17    /// No transformation (identity).
18    #[default]
19    None,
20    /// Convert all characters to uppercase.
21    Uppercase,
22    /// Convert all characters to lowercase.
23    Lowercase,
24    /// Capitalize the first character of each word.
25    Capitalize,
26}
27
28impl TextTransform {
29    /// Apply the text transform to a string.
30    ///
31    /// Uses ASCII-only transforms for determinism across locales.
32    #[must_use]
33    pub fn apply(self, text: &str) -> String {
34        match self {
35            Self::None => text.to_string(),
36            Self::Uppercase => text.to_ascii_uppercase(),
37            Self::Lowercase => text.to_ascii_lowercase(),
38            Self::Capitalize => capitalize_words(text),
39        }
40    }
41}
42
43/// Capitalize the first ASCII letter of each whitespace-delimited word.
44fn capitalize_words(text: &str) -> String {
45    let mut result = String::with_capacity(text.len());
46    let mut at_word_start = true;
47    for ch in text.chars() {
48        if ch.is_ascii_whitespace() {
49            result.push(ch);
50            at_word_start = true;
51        } else if at_word_start {
52            result.push(ch.to_ascii_uppercase());
53            at_word_start = false;
54        } else {
55            result.push(ch);
56        }
57    }
58    result
59}
60
61/// CSS-like text-overflow property for truncation behavior.
62///
63/// Controls what happens when text content overflows its container.
64#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
67pub enum TextOverflow {
68    /// Clip overflowing text at the container edge.
69    #[default]
70    Clip,
71    /// Replace overflowing text with an ellipsis ("…").
72    Ellipsis,
73    /// Replace overflowing text with a custom single-char indicator.
74    Indicator(char),
75}
76
77/// CSS-like overflow property for layout containers.
78///
79/// Controls the behavior when content exceeds the allocated area.
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
83pub enum Overflow {
84    /// Content is not clipped; may render outside the container.
85    Visible,
86    /// Content is clipped at the container boundary.
87    #[default]
88    Hidden,
89    /// Content is scrollable (requires scroll state).
90    Scroll,
91    /// Browser-like auto: scroll only when needed.
92    Auto,
93}
94
95/// CSS-like white-space property controlling wrapping and whitespace handling.
96///
97/// Maps CSS white-space modes to terminal text behavior.
98#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
101pub enum WhiteSpaceMode {
102    /// Collapse whitespace runs, wrap at container width (CSS `normal`).
103    #[default]
104    Normal,
105    /// Preserve all whitespace and newlines, no wrapping (CSS `pre`).
106    Pre,
107    /// Preserve whitespace and newlines, wrap at container width (CSS `pre-wrap`).
108    PreWrap,
109    /// Collapse whitespace runs, preserve newlines, wrap (CSS `pre-line`).
110    PreLine,
111    /// Collapse whitespace, suppress wrapping (CSS `nowrap`).
112    NoWrap,
113}
114
115impl WhiteSpaceMode {
116    /// Whether this mode collapses consecutive whitespace to a single space.
117    #[inline]
118    #[must_use]
119    pub const fn collapses_whitespace(self) -> bool {
120        matches!(self, Self::Normal | Self::PreLine | Self::NoWrap)
121    }
122
123    /// Whether this mode allows line wrapping at container width.
124    #[inline]
125    #[must_use]
126    pub const fn allows_wrap(self) -> bool {
127        matches!(self, Self::Normal | Self::PreWrap | Self::PreLine)
128    }
129
130    /// Whether this mode preserves explicit newlines in the source.
131    #[inline]
132    #[must_use]
133    pub const fn preserves_newlines(self) -> bool {
134        matches!(self, Self::Pre | Self::PreWrap | Self::PreLine)
135    }
136}
137
138/// CSS-like text-align property.
139///
140/// Controls horizontal alignment of text within its container.
141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
142#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
143#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
144pub enum TextAlign {
145    /// Left-aligned (default for LTR text).
146    #[default]
147    Left,
148    /// Right-aligned.
149    Right,
150    /// Centered.
151    Center,
152    /// Justified (stretch to fill width).
153    Justify,
154}
155
156/// Line clamp configuration.
157///
158/// Limits the number of visible lines, truncating with an ellipsis
159/// on the last visible line when content exceeds the limit.
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162pub struct LineClamp {
163    /// Maximum number of visible lines. `0` means unlimited.
164    pub max_lines: u16,
165}
166
167impl LineClamp {
168    /// Create an unlimited (no clamping) configuration.
169    pub const UNLIMITED: Self = Self { max_lines: 0 };
170
171    /// Create a line clamp with a specific maximum.
172    #[must_use]
173    pub const fn new(max_lines: u16) -> Self {
174        Self { max_lines }
175    }
176
177    /// Whether clamping is active.
178    #[inline]
179    #[must_use]
180    pub const fn is_active(self) -> bool {
181        self.max_lines > 0
182    }
183
184    /// Apply clamping to a line count: returns the number of lines to render
185    /// and whether truncation occurred.
186    #[must_use]
187    pub const fn clamp(self, line_count: usize) -> (usize, bool) {
188        if self.max_lines == 0 || line_count <= self.max_lines as usize {
189            (line_count, false)
190        } else {
191            (self.max_lines as usize, true)
192        }
193    }
194}
195
196/// Text attribute flags (16 bits for extended attribute support).
197///
198/// These flags represent visual attributes that can be applied to text.
199/// Using u16 allows for additional underline variants beyond basic SGR.
200#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
201#[repr(transparent)]
202pub struct StyleFlags(pub u16);
203
204impl StyleFlags {
205    /// No attributes set.
206    pub const NONE: Self = Self(0);
207    /// Bold / increased intensity.
208    pub const BOLD: Self = Self(1 << 0);
209    /// Dim / decreased intensity.
210    pub const DIM: Self = Self(1 << 1);
211    /// Italic text.
212    pub const ITALIC: Self = Self(1 << 2);
213    /// Single underline.
214    pub const UNDERLINE: Self = Self(1 << 3);
215    /// Blinking text.
216    pub const BLINK: Self = Self(1 << 4);
217    /// Reverse video (swap fg/bg).
218    pub const REVERSE: Self = Self(1 << 5);
219    /// Hidden / invisible text.
220    pub const HIDDEN: Self = Self(1 << 6);
221    /// Strikethrough text.
222    pub const STRIKETHROUGH: Self = Self(1 << 7);
223    /// Double underline (extended attribute).
224    pub const DOUBLE_UNDERLINE: Self = Self(1 << 8);
225    /// Curly / wavy underline (extended attribute).
226    pub const CURLY_UNDERLINE: Self = Self(1 << 9);
227
228    /// Check if this flags set contains another flags set.
229    #[inline]
230    pub const fn contains(self, other: Self) -> bool {
231        (self.0 & other.0) == other.0
232    }
233
234    /// Insert flags into this set.
235    #[inline]
236    pub fn insert(&mut self, other: Self) {
237        self.0 |= other.0;
238    }
239
240    /// Remove flags from this set.
241    #[inline]
242    pub fn remove(&mut self, other: Self) {
243        self.0 &= !other.0;
244    }
245
246    /// Check if the flags set is empty.
247    #[inline]
248    pub const fn is_empty(self) -> bool {
249        self.0 == 0
250    }
251
252    /// Combine two flag sets (OR operation).
253    #[inline]
254    #[must_use]
255    pub const fn union(self, other: Self) -> Self {
256        Self(self.0 | other.0)
257    }
258}
259
260impl core::ops::BitOr for StyleFlags {
261    type Output = Self;
262
263    #[inline]
264    fn bitor(self, rhs: Self) -> Self::Output {
265        Self(self.0 | rhs.0)
266    }
267}
268
269impl core::ops::BitOrAssign for StyleFlags {
270    #[inline]
271    fn bitor_assign(&mut self, rhs: Self) {
272        self.0 |= rhs.0;
273    }
274}
275
276/// Unified styling type with CSS-like cascading semantics.
277///
278/// # Design Rationale
279/// - Option fields allow inheritance (None = inherit from parent)
280/// - Explicit masks track which properties are intentionally set
281/// - Copy + small size for cheap passing
282/// - Builder pattern for ergonomic construction
283///
284/// # Example
285/// ```
286/// use ftui_style::{Style, StyleFlags};
287/// use ftui_render::cell::PackedRgba;
288///
289/// let style = Style::new()
290///     .fg(PackedRgba::rgb(255, 0, 0))
291///     .bg(PackedRgba::rgb(0, 0, 0))
292///     .bold()
293///     .underline();
294/// ```
295#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
296pub struct Style {
297    /// Foreground color (text color).
298    pub fg: Option<PackedRgba>,
299    /// Background color.
300    pub bg: Option<PackedRgba>,
301    /// Text attributes (bold, italic, etc.).
302    pub attrs: Option<StyleFlags>,
303    /// Underline color (separate from fg for flexibility).
304    pub underline_color: Option<PackedRgba>,
305}
306
307impl Style {
308    /// Create an empty style (all properties inherit).
309    #[inline]
310    pub const fn new() -> Self {
311        Self {
312            fg: None,
313            bg: None,
314            attrs: None,
315            underline_color: None,
316        }
317    }
318
319    /// Set foreground color.
320    #[inline]
321    #[must_use]
322    pub fn fg<C: Into<PackedRgba>>(mut self, color: C) -> Self {
323        self.fg = Some(color.into());
324        self
325    }
326
327    /// Set background color.
328    #[inline]
329    #[must_use]
330    pub fn bg<C: Into<PackedRgba>>(mut self, color: C) -> Self {
331        self.bg = Some(color.into());
332        self
333    }
334
335    /// Add bold attribute.
336    #[inline]
337    #[must_use]
338    pub fn bold(self) -> Self {
339        self.add_attr(StyleFlags::BOLD)
340    }
341
342    /// Add italic attribute.
343    #[inline]
344    #[must_use]
345    pub fn italic(self) -> Self {
346        self.add_attr(StyleFlags::ITALIC)
347    }
348
349    /// Add underline attribute.
350    #[inline]
351    #[must_use]
352    pub fn underline(self) -> Self {
353        self.add_attr(StyleFlags::UNDERLINE)
354    }
355
356    /// Add dim attribute.
357    #[inline]
358    #[must_use]
359    pub fn dim(self) -> Self {
360        self.add_attr(StyleFlags::DIM)
361    }
362
363    /// Add reverse video attribute.
364    #[inline]
365    #[must_use]
366    pub fn reverse(self) -> Self {
367        self.add_attr(StyleFlags::REVERSE)
368    }
369
370    /// Add strikethrough attribute.
371    #[inline]
372    #[must_use]
373    pub fn strikethrough(self) -> Self {
374        self.add_attr(StyleFlags::STRIKETHROUGH)
375    }
376
377    /// Add blink attribute.
378    #[inline]
379    #[must_use]
380    pub fn blink(self) -> Self {
381        self.add_attr(StyleFlags::BLINK)
382    }
383
384    /// Add hidden attribute.
385    #[inline]
386    #[must_use]
387    pub fn hidden(self) -> Self {
388        self.add_attr(StyleFlags::HIDDEN)
389    }
390
391    /// Add double underline attribute.
392    #[inline]
393    #[must_use]
394    pub fn double_underline(self) -> Self {
395        self.add_attr(StyleFlags::DOUBLE_UNDERLINE)
396    }
397
398    /// Add curly underline attribute.
399    #[inline]
400    #[must_use]
401    pub fn curly_underline(self) -> Self {
402        self.add_attr(StyleFlags::CURLY_UNDERLINE)
403    }
404
405    /// Add an attribute flag.
406    #[inline]
407    fn add_attr(mut self, flag: StyleFlags) -> Self {
408        match &mut self.attrs {
409            Some(attrs) => attrs.insert(flag),
410            None => self.attrs = Some(flag),
411        }
412        self
413    }
414
415    /// Set underline color.
416    #[inline]
417    #[must_use]
418    pub const fn underline_color(mut self, color: PackedRgba) -> Self {
419        self.underline_color = Some(color);
420        self
421    }
422
423    /// Set attributes directly.
424    #[inline]
425    #[must_use]
426    pub const fn attrs(mut self, attrs: StyleFlags) -> Self {
427        self.attrs = Some(attrs);
428        self
429    }
430
431    /// Cascade merge: Fill in None fields from parent.
432    ///
433    /// `child.merge(parent)` returns a style where child's Some values
434    /// take precedence, and parent fills in any None values.
435    ///
436    /// For attributes, the flags are combined (OR operation) so both
437    /// parent and child attributes apply.
438    ///
439    /// # Example
440    /// ```
441    /// use ftui_style::Style;
442    /// use ftui_render::cell::PackedRgba;
443    ///
444    /// let parent = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
445    /// let child = Style::new().bg(PackedRgba::rgb(0, 0, 255));
446    /// let merged = child.merge(&parent);
447    /// // merged has: fg=RED (from parent), bg=BLUE (from child), bold (from parent)
448    /// ```
449    #[instrument(skip(self, parent), level = "trace")]
450    pub fn merge(&self, parent: &Style) -> Style {
451        trace!("Merging child style into parent");
452        Style {
453            fg: self.fg.or(parent.fg),
454            bg: self.bg.or(parent.bg),
455            attrs: match (self.attrs, parent.attrs) {
456                (Some(c), Some(p)) => Some(c.union(p)),
457                (Some(c), None) => Some(c),
458                (None, Some(p)) => Some(p),
459                (None, None) => None,
460            },
461            underline_color: self.underline_color.or(parent.underline_color),
462        }
463    }
464
465    /// Patch merge: Override parent with child's Some values.
466    ///
467    /// `parent.patch(&child)` returns a style where child's Some values
468    /// replace parent's values.
469    ///
470    /// This is the inverse perspective of merge().
471    #[inline]
472    pub fn patch(&self, child: &Style) -> Style {
473        child.merge(self)
474    }
475
476    /// Check if this style has any properties set.
477    #[inline]
478    pub const fn is_empty(&self) -> bool {
479        self.fg.is_none()
480            && self.bg.is_none()
481            && self.attrs.is_none()
482            && self.underline_color.is_none()
483    }
484
485    /// Check if a specific attribute is set.
486    #[inline]
487    pub fn has_attr(&self, flag: StyleFlags) -> bool {
488        self.attrs.is_some_and(|a| a.contains(flag))
489    }
490}
491
492/// Convert from cell-level StyleFlags (8-bit) to style-level StyleFlags (16-bit).
493impl From<ftui_render::cell::StyleFlags> for StyleFlags {
494    fn from(flags: ftui_render::cell::StyleFlags) -> Self {
495        let mut result = StyleFlags::NONE;
496        if flags.contains(ftui_render::cell::StyleFlags::BOLD) {
497            result.insert(StyleFlags::BOLD);
498        }
499        if flags.contains(ftui_render::cell::StyleFlags::DIM) {
500            result.insert(StyleFlags::DIM);
501        }
502        if flags.contains(ftui_render::cell::StyleFlags::ITALIC) {
503            result.insert(StyleFlags::ITALIC);
504        }
505        if flags.contains(ftui_render::cell::StyleFlags::UNDERLINE) {
506            result.insert(StyleFlags::UNDERLINE);
507        }
508        if flags.contains(ftui_render::cell::StyleFlags::BLINK) {
509            result.insert(StyleFlags::BLINK);
510        }
511        if flags.contains(ftui_render::cell::StyleFlags::REVERSE) {
512            result.insert(StyleFlags::REVERSE);
513        }
514        if flags.contains(ftui_render::cell::StyleFlags::STRIKETHROUGH) {
515            result.insert(StyleFlags::STRIKETHROUGH);
516        }
517        if flags.contains(ftui_render::cell::StyleFlags::HIDDEN) {
518            result.insert(StyleFlags::HIDDEN);
519        }
520        result
521    }
522}
523
524/// Convert from style-level StyleFlags (16-bit) to cell-level StyleFlags (8-bit).
525///
526/// Note: Extended flags (DOUBLE_UNDERLINE, CURLY_UNDERLINE) are mapped to
527/// basic UNDERLINE since the cell-level representation doesn't support them.
528impl From<StyleFlags> for ftui_render::cell::StyleFlags {
529    fn from(flags: StyleFlags) -> Self {
530        use ftui_render::cell::StyleFlags as CellFlags;
531        let mut result = CellFlags::empty();
532        if flags.contains(StyleFlags::BOLD) {
533            result |= CellFlags::BOLD;
534        }
535        if flags.contains(StyleFlags::DIM) {
536            result |= CellFlags::DIM;
537        }
538        if flags.contains(StyleFlags::ITALIC) {
539            result |= CellFlags::ITALIC;
540        }
541        // Map all underline variants to basic underline
542        if flags.contains(StyleFlags::UNDERLINE)
543            || flags.contains(StyleFlags::DOUBLE_UNDERLINE)
544            || flags.contains(StyleFlags::CURLY_UNDERLINE)
545        {
546            result |= CellFlags::UNDERLINE;
547        }
548        if flags.contains(StyleFlags::BLINK) {
549            result |= CellFlags::BLINK;
550        }
551        if flags.contains(StyleFlags::REVERSE) {
552            result |= CellFlags::REVERSE;
553        }
554        if flags.contains(StyleFlags::STRIKETHROUGH) {
555            result |= CellFlags::STRIKETHROUGH;
556        }
557        if flags.contains(StyleFlags::HIDDEN) {
558            result |= CellFlags::HIDDEN;
559        }
560        result
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_default_is_empty() {
570        let s = Style::default();
571        assert!(s.is_empty());
572        assert_eq!(s.fg, None);
573        assert_eq!(s.bg, None);
574        assert_eq!(s.attrs, None);
575        assert_eq!(s.underline_color, None);
576    }
577
578    #[test]
579    fn test_new_is_empty() {
580        let s = Style::new();
581        assert!(s.is_empty());
582    }
583
584    #[test]
585    fn test_builder_pattern_colors() {
586        let red = PackedRgba::rgb(255, 0, 0);
587        let black = PackedRgba::rgb(0, 0, 0);
588
589        let s = Style::new().fg(red).bg(black);
590
591        assert_eq!(s.fg, Some(red));
592        assert_eq!(s.bg, Some(black));
593        assert!(!s.is_empty());
594    }
595
596    #[test]
597    fn test_builder_pattern_attrs() {
598        let s = Style::new().bold().underline().italic();
599
600        assert!(s.has_attr(StyleFlags::BOLD));
601        assert!(s.has_attr(StyleFlags::UNDERLINE));
602        assert!(s.has_attr(StyleFlags::ITALIC));
603        assert!(!s.has_attr(StyleFlags::DIM));
604    }
605
606    #[test]
607    fn test_all_attribute_builders() {
608        let s = Style::new()
609            .bold()
610            .dim()
611            .italic()
612            .underline()
613            .blink()
614            .reverse()
615            .hidden()
616            .strikethrough()
617            .double_underline()
618            .curly_underline();
619
620        assert!(s.has_attr(StyleFlags::BOLD));
621        assert!(s.has_attr(StyleFlags::DIM));
622        assert!(s.has_attr(StyleFlags::ITALIC));
623        assert!(s.has_attr(StyleFlags::UNDERLINE));
624        assert!(s.has_attr(StyleFlags::BLINK));
625        assert!(s.has_attr(StyleFlags::REVERSE));
626        assert!(s.has_attr(StyleFlags::HIDDEN));
627        assert!(s.has_attr(StyleFlags::STRIKETHROUGH));
628        assert!(s.has_attr(StyleFlags::DOUBLE_UNDERLINE));
629        assert!(s.has_attr(StyleFlags::CURLY_UNDERLINE));
630    }
631
632    #[test]
633    fn test_merge_child_wins_on_conflict() {
634        let red = PackedRgba::rgb(255, 0, 0);
635        let blue = PackedRgba::rgb(0, 0, 255);
636
637        let parent = Style::new().fg(red);
638        let child = Style::new().fg(blue);
639        let merged = child.merge(&parent);
640
641        assert_eq!(merged.fg, Some(blue)); // Child wins
642    }
643
644    #[test]
645    fn test_merge_parent_fills_gaps() {
646        let red = PackedRgba::rgb(255, 0, 0);
647        let blue = PackedRgba::rgb(0, 0, 255);
648        let white = PackedRgba::rgb(255, 255, 255);
649
650        let parent = Style::new().fg(red).bg(white);
651        let child = Style::new().fg(blue); // No bg
652        let merged = child.merge(&parent);
653
654        assert_eq!(merged.fg, Some(blue)); // Child fg
655        assert_eq!(merged.bg, Some(white)); // Parent fills bg
656    }
657
658    #[test]
659    fn test_merge_attrs_combine() {
660        let parent = Style::new().bold();
661        let child = Style::new().italic();
662        let merged = child.merge(&parent);
663
664        assert!(merged.has_attr(StyleFlags::BOLD)); // From parent
665        assert!(merged.has_attr(StyleFlags::ITALIC)); // From child
666    }
667
668    #[test]
669    fn test_merge_with_empty_returns_self() {
670        let red = PackedRgba::rgb(255, 0, 0);
671        let style = Style::new().fg(red).bold();
672        let empty = Style::default();
673
674        let merged = style.merge(&empty);
675        assert_eq!(merged, style);
676    }
677
678    #[test]
679    fn test_empty_merge_with_parent() {
680        let red = PackedRgba::rgb(255, 0, 0);
681        let parent = Style::new().fg(red).bold();
682        let child = Style::default();
683
684        let merged = child.merge(&parent);
685        assert_eq!(merged, parent);
686    }
687
688    #[test]
689    fn test_patch_is_symmetric_with_merge() {
690        let red = PackedRgba::rgb(255, 0, 0);
691        let blue = PackedRgba::rgb(0, 0, 255);
692
693        let parent = Style::new().fg(red);
694        let child = Style::new().bg(blue);
695
696        let merged1 = child.merge(&parent);
697        let merged2 = parent.patch(&child);
698
699        assert_eq!(merged1, merged2);
700    }
701
702    #[test]
703    fn test_underline_color() {
704        let red = PackedRgba::rgb(255, 0, 0);
705        let s = Style::new().underline().underline_color(red);
706
707        assert!(s.has_attr(StyleFlags::UNDERLINE));
708        assert_eq!(s.underline_color, Some(red));
709    }
710
711    #[test]
712    fn test_style_flags_operations() {
713        let mut flags = StyleFlags::NONE;
714        assert!(flags.is_empty());
715
716        flags.insert(StyleFlags::BOLD);
717        flags.insert(StyleFlags::ITALIC);
718
719        assert!(flags.contains(StyleFlags::BOLD));
720        assert!(flags.contains(StyleFlags::ITALIC));
721        assert!(!flags.contains(StyleFlags::UNDERLINE));
722        assert!(!flags.is_empty());
723
724        flags.remove(StyleFlags::BOLD);
725        assert!(!flags.contains(StyleFlags::BOLD));
726        assert!(flags.contains(StyleFlags::ITALIC));
727    }
728
729    #[test]
730    fn test_style_flags_bitor() {
731        let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
732        assert!(flags.contains(StyleFlags::BOLD));
733        assert!(flags.contains(StyleFlags::ITALIC));
734    }
735
736    #[test]
737    fn test_style_flags_bitor_assign() {
738        let mut flags = StyleFlags::BOLD;
739        flags |= StyleFlags::ITALIC;
740        assert!(flags.contains(StyleFlags::BOLD));
741        assert!(flags.contains(StyleFlags::ITALIC));
742    }
743
744    #[test]
745    fn test_style_flags_union() {
746        let a = StyleFlags::BOLD;
747        let b = StyleFlags::ITALIC;
748        let c = a.union(b);
749        assert!(c.contains(StyleFlags::BOLD));
750        assert!(c.contains(StyleFlags::ITALIC));
751    }
752
753    #[test]
754    fn test_style_size() {
755        // Style should fit in a reasonable size
756        // 4 Option<PackedRgba> = 4 * 8 = 32 bytes (with Option overhead)
757        // + 1 Option<StyleFlags> = 4 bytes
758        // Total should be <= 40 bytes
759        assert!(
760            core::mem::size_of::<Style>() <= 40,
761            "Style is {} bytes, expected <= 40",
762            core::mem::size_of::<Style>()
763        );
764    }
765
766    #[test]
767    fn test_style_flags_size() {
768        assert_eq!(core::mem::size_of::<StyleFlags>(), 2);
769    }
770
771    #[test]
772    fn test_convert_to_cell_flags() {
773        let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
774        let cell_flags: ftui_render::cell::StyleFlags = flags.into();
775
776        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::BOLD));
777        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::ITALIC));
778        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::UNDERLINE));
779    }
780
781    #[test]
782    fn test_convert_to_cell_flags_all_basic() {
783        let flags = StyleFlags::BOLD
784            | StyleFlags::DIM
785            | StyleFlags::ITALIC
786            | StyleFlags::UNDERLINE
787            | StyleFlags::BLINK
788            | StyleFlags::REVERSE
789            | StyleFlags::STRIKETHROUGH
790            | StyleFlags::HIDDEN;
791        let cell_flags: ftui_render::cell::StyleFlags = flags.into();
792
793        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::BOLD));
794        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::DIM));
795        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::ITALIC));
796        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::UNDERLINE));
797        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::BLINK));
798        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::REVERSE));
799        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::STRIKETHROUGH));
800        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::HIDDEN));
801    }
802
803    #[test]
804    fn test_convert_from_cell_flags() {
805        use ftui_render::cell::StyleFlags as CellFlags;
806        let cell_flags = CellFlags::BOLD | CellFlags::ITALIC;
807        let style_flags: StyleFlags = cell_flags.into();
808
809        assert!(style_flags.contains(StyleFlags::BOLD));
810        assert!(style_flags.contains(StyleFlags::ITALIC));
811    }
812
813    #[test]
814    fn test_cell_flags_round_trip_preserves_basic_flags() {
815        use ftui_render::cell::StyleFlags as CellFlags;
816        let original = StyleFlags::BOLD
817            | StyleFlags::DIM
818            | StyleFlags::ITALIC
819            | StyleFlags::UNDERLINE
820            | StyleFlags::BLINK
821            | StyleFlags::REVERSE
822            | StyleFlags::STRIKETHROUGH
823            | StyleFlags::HIDDEN;
824        let cell_flags: CellFlags = original.into();
825        let round_trip: StyleFlags = cell_flags.into();
826
827        assert!(round_trip.contains(StyleFlags::BOLD));
828        assert!(round_trip.contains(StyleFlags::DIM));
829        assert!(round_trip.contains(StyleFlags::ITALIC));
830        assert!(round_trip.contains(StyleFlags::UNDERLINE));
831        assert!(round_trip.contains(StyleFlags::BLINK));
832        assert!(round_trip.contains(StyleFlags::REVERSE));
833        assert!(round_trip.contains(StyleFlags::STRIKETHROUGH));
834        assert!(round_trip.contains(StyleFlags::HIDDEN));
835    }
836
837    #[test]
838    fn test_extended_underline_maps_to_basic() {
839        let flags = StyleFlags::DOUBLE_UNDERLINE | StyleFlags::CURLY_UNDERLINE;
840        let cell_flags: ftui_render::cell::StyleFlags = flags.into();
841
842        // Extended underlines map to basic underline in cell representation
843        assert!(cell_flags.contains(ftui_render::cell::StyleFlags::UNDERLINE));
844    }
845}
846
847#[cfg(test)]
848mod property_tests {
849    use super::*;
850    use proptest::prelude::*;
851
852    fn arb_packed_rgba() -> impl Strategy<Value = PackedRgba> {
853        any::<u32>().prop_map(PackedRgba)
854    }
855
856    fn arb_style_flags() -> impl Strategy<Value = StyleFlags> {
857        any::<u16>().prop_map(StyleFlags)
858    }
859
860    fn arb_style() -> impl Strategy<Value = Style> {
861        (
862            proptest::option::of(arb_packed_rgba()),
863            proptest::option::of(arb_packed_rgba()),
864            proptest::option::of(arb_style_flags()),
865            proptest::option::of(arb_packed_rgba()),
866        )
867            .prop_map(|(fg, bg, attrs, underline_color)| Style {
868                fg,
869                bg,
870                attrs,
871                underline_color,
872            })
873    }
874
875    proptest! {
876        #[test]
877        fn merge_with_empty_is_identity(s in arb_style()) {
878            let empty = Style::default();
879            prop_assert_eq!(s.merge(&empty), s);
880        }
881
882        #[test]
883        fn empty_merge_with_any_equals_any(parent in arb_style()) {
884            let empty = Style::default();
885            prop_assert_eq!(empty.merge(&parent), parent);
886        }
887
888        #[test]
889        fn merge_is_deterministic(a in arb_style(), b in arb_style()) {
890            let merged1 = a.merge(&b);
891            let merged2 = a.merge(&b);
892            prop_assert_eq!(merged1, merged2);
893        }
894
895        #[test]
896        fn patch_equals_reverse_merge(parent in arb_style(), child in arb_style()) {
897            let via_merge = child.merge(&parent);
898            let via_patch = parent.patch(&child);
899            prop_assert_eq!(via_merge, via_patch);
900        }
901
902        #[test]
903        fn style_flags_union_is_commutative(a in arb_style_flags(), b in arb_style_flags()) {
904            prop_assert_eq!(a.union(b), b.union(a));
905        }
906
907        #[test]
908        fn style_flags_union_is_associative(
909            a in arb_style_flags(),
910            b in arb_style_flags(),
911            c in arb_style_flags()
912        ) {
913            prop_assert_eq!(a.union(b).union(c), a.union(b.union(c)));
914        }
915    }
916}
917
918#[cfg(test)]
919mod merge_semantic_tests {
920    //! Tests for merge behavior and determinism.
921    //!
922    //! These tests verify the cascading semantics: child overrides parent
923    //! for colors, and flags combine for attributes.
924
925    use super::*;
926
927    #[test]
928    fn merge_chain_three_styles() {
929        // Test merging a chain: grandchild -> child -> parent
930        let red = PackedRgba::rgb(255, 0, 0);
931        let green = PackedRgba::rgb(0, 255, 0);
932        let blue = PackedRgba::rgb(0, 0, 255);
933        let white = PackedRgba::rgb(255, 255, 255);
934
935        let grandparent = Style::new().fg(red).bg(white).bold();
936        let parent = Style::new().fg(green).italic();
937        let child = Style::new().fg(blue);
938
939        // First merge: parent <- grandparent
940        let parent_merged = parent.merge(&grandparent);
941        assert_eq!(parent_merged.fg, Some(green)); // parent wins
942        assert_eq!(parent_merged.bg, Some(white)); // inherited from grandparent
943        assert!(parent_merged.has_attr(StyleFlags::BOLD)); // inherited
944        assert!(parent_merged.has_attr(StyleFlags::ITALIC)); // parent's
945
946        // Second merge: child <- parent_merged
947        let child_merged = child.merge(&parent_merged);
948        assert_eq!(child_merged.fg, Some(blue)); // child wins
949        assert_eq!(child_merged.bg, Some(white)); // inherited from grandparent
950        assert!(child_merged.has_attr(StyleFlags::BOLD)); // inherited
951        assert!(child_merged.has_attr(StyleFlags::ITALIC)); // inherited
952    }
953
954    #[test]
955    fn merge_chain_attrs_accumulate() {
956        // Attributes from all ancestors should accumulate
957        let s1 = Style::new().bold();
958        let s2 = Style::new().italic();
959        let s3 = Style::new().underline();
960
961        let merged = s3.merge(&s2.merge(&s1));
962
963        assert!(merged.has_attr(StyleFlags::BOLD));
964        assert!(merged.has_attr(StyleFlags::ITALIC));
965        assert!(merged.has_attr(StyleFlags::UNDERLINE));
966    }
967
968    #[test]
969    fn has_attr_returns_false_for_none() {
970        let style = Style::new(); // attrs is None
971        assert!(!style.has_attr(StyleFlags::BOLD));
972        assert!(!style.has_attr(StyleFlags::ITALIC));
973        assert!(!style.has_attr(StyleFlags::NONE));
974    }
975
976    #[test]
977    fn has_attr_returns_true_for_set_flags() {
978        let style = Style::new().bold().italic();
979        assert!(style.has_attr(StyleFlags::BOLD));
980        assert!(style.has_attr(StyleFlags::ITALIC));
981        assert!(!style.has_attr(StyleFlags::UNDERLINE));
982    }
983
984    #[test]
985    fn attrs_method_sets_directly() {
986        let flags = StyleFlags::BOLD | StyleFlags::DIM | StyleFlags::ITALIC;
987        let style = Style::new().attrs(flags);
988
989        assert_eq!(style.attrs, Some(flags));
990        assert!(style.has_attr(StyleFlags::BOLD));
991        assert!(style.has_attr(StyleFlags::DIM));
992        assert!(style.has_attr(StyleFlags::ITALIC));
993    }
994
995    #[test]
996    fn attrs_method_overwrites_previous() {
997        let style = Style::new().bold().italic().attrs(StyleFlags::UNDERLINE); // overwrites, doesn't combine
998
999        assert!(style.has_attr(StyleFlags::UNDERLINE));
1000        // Bold and italic are NOT preserved when using attrs() directly
1001        assert!(!style.has_attr(StyleFlags::BOLD));
1002        assert!(!style.has_attr(StyleFlags::ITALIC));
1003    }
1004
1005    #[test]
1006    fn merge_preserves_explicit_transparent_color() {
1007        // TRANSPARENT is a valid explicit color, should not be treated as "unset"
1008        let transparent = PackedRgba::TRANSPARENT;
1009        let red = PackedRgba::rgb(255, 0, 0);
1010
1011        let parent = Style::new().fg(red);
1012        let child = Style::new().fg(transparent);
1013
1014        let merged = child.merge(&parent);
1015        // Child explicitly sets transparent, should win over parent's red
1016        assert_eq!(merged.fg, Some(transparent));
1017    }
1018
1019    #[test]
1020    fn merge_all_fields_independently() {
1021        let parent = Style::new()
1022            .fg(PackedRgba::rgb(1, 1, 1))
1023            .bg(PackedRgba::rgb(2, 2, 2))
1024            .underline_color(PackedRgba::rgb(3, 3, 3))
1025            .bold();
1026
1027        let child = Style::new()
1028            .fg(PackedRgba::rgb(10, 10, 10))
1029            // no bg - should inherit
1030            .underline_color(PackedRgba::rgb(30, 30, 30))
1031            .italic();
1032
1033        let merged = child.merge(&parent);
1034
1035        // Child overrides fg
1036        assert_eq!(merged.fg, Some(PackedRgba::rgb(10, 10, 10)));
1037        // Parent fills bg
1038        assert_eq!(merged.bg, Some(PackedRgba::rgb(2, 2, 2)));
1039        // Child overrides underline_color
1040        assert_eq!(merged.underline_color, Some(PackedRgba::rgb(30, 30, 30)));
1041        // Both attrs combined
1042        assert!(merged.has_attr(StyleFlags::BOLD));
1043        assert!(merged.has_attr(StyleFlags::ITALIC));
1044    }
1045
1046    #[test]
1047    fn style_is_copy() {
1048        let style = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
1049        let copy = style; // Copy, not move
1050        assert_eq!(style, copy);
1051    }
1052
1053    #[test]
1054    fn style_is_eq() {
1055        let a = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
1056        let b = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
1057        let c = Style::new().fg(PackedRgba::rgb(0, 255, 0)).bold();
1058
1059        assert_eq!(a, b);
1060        assert_ne!(a, c);
1061    }
1062
1063    #[test]
1064    fn style_is_hashable() {
1065        use std::collections::HashSet;
1066        let mut set = HashSet::new();
1067
1068        let a = Style::new().fg(PackedRgba::rgb(255, 0, 0)).bold();
1069        let b = Style::new().fg(PackedRgba::rgb(0, 255, 0)).italic();
1070
1071        set.insert(a);
1072        set.insert(b);
1073        set.insert(a); // duplicate
1074
1075        assert_eq!(set.len(), 2);
1076    }
1077
1078    #[test]
1079    fn style_flags_contains_combined() {
1080        let combined = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
1081
1082        // contains should return true for individual flags
1083        assert!(combined.contains(StyleFlags::BOLD));
1084        assert!(combined.contains(StyleFlags::ITALIC));
1085        assert!(combined.contains(StyleFlags::UNDERLINE));
1086
1087        // contains should return true for subsets
1088        assert!(combined.contains(StyleFlags::BOLD | StyleFlags::ITALIC));
1089
1090        // contains should return false for non-subset
1091        assert!(!combined.contains(StyleFlags::DIM));
1092        assert!(!combined.contains(StyleFlags::BOLD | StyleFlags::DIM));
1093    }
1094
1095    #[test]
1096    fn style_flags_none_is_identity_for_union() {
1097        let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
1098        assert_eq!(flags.union(StyleFlags::NONE), flags);
1099        assert_eq!(StyleFlags::NONE.union(flags), flags);
1100    }
1101
1102    #[test]
1103    fn style_flags_remove_nonexistent_is_noop() {
1104        let mut flags = StyleFlags::BOLD;
1105        flags.remove(StyleFlags::ITALIC); // Not set, should be no-op
1106        assert!(flags.contains(StyleFlags::BOLD));
1107        assert!(!flags.contains(StyleFlags::ITALIC));
1108    }
1109}
1110
1111#[cfg(test)]
1112mod performance_tests {
1113    use super::*;
1114
1115    #[test]
1116    fn test_style_merge_performance() {
1117        let red = PackedRgba::rgb(255, 0, 0);
1118        let blue = PackedRgba::rgb(0, 0, 255);
1119
1120        let parent = Style::new().fg(red).bold();
1121        let child = Style::new().bg(blue).italic();
1122
1123        let start = std::time::Instant::now();
1124        for _ in 0..1_000_000 {
1125            let _ = std::hint::black_box(child.merge(&parent));
1126        }
1127        let elapsed = start.elapsed();
1128
1129        // 1M merges should be < 100ms (< 100ns each)
1130        // Being generous with threshold for CI variability
1131        assert!(
1132            elapsed.as_millis() < 100,
1133            "Merge too slow: {:?} for 1M iterations",
1134            elapsed
1135        );
1136    }
1137}
1138
1139#[cfg(test)]
1140mod parity_tests {
1141    use super::*;
1142
1143    // ── TextTransform ─────────────────────────────────────────────
1144
1145    #[test]
1146    fn text_transform_none_is_identity() {
1147        assert_eq!(TextTransform::None.apply("Hello World"), "Hello World");
1148    }
1149
1150    #[test]
1151    fn text_transform_uppercase() {
1152        assert_eq!(TextTransform::Uppercase.apply("hello world"), "HELLO WORLD");
1153    }
1154
1155    #[test]
1156    fn text_transform_lowercase() {
1157        assert_eq!(TextTransform::Lowercase.apply("HELLO WORLD"), "hello world");
1158    }
1159
1160    #[test]
1161    fn text_transform_capitalize() {
1162        assert_eq!(
1163            TextTransform::Capitalize.apply("hello world"),
1164            "Hello World"
1165        );
1166        assert_eq!(
1167            TextTransform::Capitalize.apply("  two  spaces"),
1168            "  Two  Spaces"
1169        );
1170    }
1171
1172    #[test]
1173    fn text_transform_empty_string() {
1174        assert_eq!(TextTransform::Uppercase.apply(""), "");
1175        assert_eq!(TextTransform::Capitalize.apply(""), "");
1176    }
1177
1178    #[test]
1179    fn text_transform_default_is_none() {
1180        assert_eq!(TextTransform::default(), TextTransform::None);
1181    }
1182
1183    // ── TextOverflow ──────────────────────────────────────────────
1184
1185    #[test]
1186    fn text_overflow_default_is_clip() {
1187        assert_eq!(TextOverflow::default(), TextOverflow::Clip);
1188    }
1189
1190    #[test]
1191    fn text_overflow_indicator_stores_char() {
1192        let overflow = TextOverflow::Indicator('>');
1193        assert_eq!(overflow, TextOverflow::Indicator('>'));
1194    }
1195
1196    // ── Overflow ──────────────────────────────────────────────────
1197
1198    #[test]
1199    fn overflow_default_is_hidden() {
1200        assert_eq!(Overflow::default(), Overflow::Hidden);
1201    }
1202
1203    // ── WhiteSpaceMode ───────────────────────────────────────────
1204
1205    #[test]
1206    fn whitespace_normal_collapses_and_wraps() {
1207        let mode = WhiteSpaceMode::Normal;
1208        assert!(mode.collapses_whitespace());
1209        assert!(mode.allows_wrap());
1210        assert!(!mode.preserves_newlines());
1211    }
1212
1213    #[test]
1214    fn whitespace_pre_preserves_all() {
1215        let mode = WhiteSpaceMode::Pre;
1216        assert!(!mode.collapses_whitespace());
1217        assert!(!mode.allows_wrap());
1218        assert!(mode.preserves_newlines());
1219    }
1220
1221    #[test]
1222    fn whitespace_pre_wrap_preserves_and_wraps() {
1223        let mode = WhiteSpaceMode::PreWrap;
1224        assert!(!mode.collapses_whitespace());
1225        assert!(mode.allows_wrap());
1226        assert!(mode.preserves_newlines());
1227    }
1228
1229    #[test]
1230    fn whitespace_pre_line_collapses_preserves_newlines_and_wraps() {
1231        let mode = WhiteSpaceMode::PreLine;
1232        assert!(mode.collapses_whitespace());
1233        assert!(mode.allows_wrap());
1234        assert!(mode.preserves_newlines());
1235    }
1236
1237    #[test]
1238    fn whitespace_nowrap_collapses_no_wrap() {
1239        let mode = WhiteSpaceMode::NoWrap;
1240        assert!(mode.collapses_whitespace());
1241        assert!(!mode.allows_wrap());
1242        assert!(!mode.preserves_newlines());
1243    }
1244
1245    #[test]
1246    fn whitespace_default_is_normal() {
1247        assert_eq!(WhiteSpaceMode::default(), WhiteSpaceMode::Normal);
1248    }
1249
1250    // ── TextAlign ────────────────────────────────────────────────
1251
1252    #[test]
1253    fn text_align_default_is_left() {
1254        assert_eq!(TextAlign::default(), TextAlign::Left);
1255    }
1256
1257    // ── LineClamp ────────────────────────────────────────────────
1258
1259    #[test]
1260    fn line_clamp_unlimited() {
1261        let clamp = LineClamp::UNLIMITED;
1262        assert!(!clamp.is_active());
1263        assert_eq!(clamp.clamp(100), (100, false));
1264    }
1265
1266    #[test]
1267    fn line_clamp_active() {
1268        let clamp = LineClamp::new(3);
1269        assert!(clamp.is_active());
1270        assert_eq!(clamp.clamp(5), (3, true));
1271        assert_eq!(clamp.clamp(3), (3, false));
1272        assert_eq!(clamp.clamp(1), (1, false));
1273    }
1274
1275    #[test]
1276    fn line_clamp_default_is_unlimited() {
1277        let clamp = LineClamp::default();
1278        assert!(!clamp.is_active());
1279        assert_eq!(clamp.max_lines, 0);
1280    }
1281}