1#![forbid(unsafe_code)]
2
3use ftui_render::cell::PackedRgba;
6use tracing::{instrument, trace};
7
8#[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 #[default]
19 None,
20 Uppercase,
22 Lowercase,
24 Capitalize,
26}
27
28impl TextTransform {
29 #[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
43fn 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#[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 #[default]
70 Clip,
71 Ellipsis,
73 Indicator(char),
75}
76
77#[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 Visible,
86 #[default]
88 Hidden,
89 Scroll,
91 Auto,
93}
94
95#[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 #[default]
104 Normal,
105 Pre,
107 PreWrap,
109 PreLine,
111 NoWrap,
113}
114
115impl WhiteSpaceMode {
116 #[inline]
118 #[must_use]
119 pub const fn collapses_whitespace(self) -> bool {
120 matches!(self, Self::Normal | Self::PreLine | Self::NoWrap)
121 }
122
123 #[inline]
125 #[must_use]
126 pub const fn allows_wrap(self) -> bool {
127 matches!(self, Self::Normal | Self::PreWrap | Self::PreLine)
128 }
129
130 #[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#[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 #[default]
147 Left,
148 Right,
150 Center,
152 Justify,
154}
155
156#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162pub struct LineClamp {
163 pub max_lines: u16,
165}
166
167impl LineClamp {
168 pub const UNLIMITED: Self = Self { max_lines: 0 };
170
171 #[must_use]
173 pub const fn new(max_lines: u16) -> Self {
174 Self { max_lines }
175 }
176
177 #[inline]
179 #[must_use]
180 pub const fn is_active(self) -> bool {
181 self.max_lines > 0
182 }
183
184 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
201#[repr(transparent)]
202pub struct StyleFlags(pub u16);
203
204impl StyleFlags {
205 pub const NONE: Self = Self(0);
207 pub const BOLD: Self = Self(1 << 0);
209 pub const DIM: Self = Self(1 << 1);
211 pub const ITALIC: Self = Self(1 << 2);
213 pub const UNDERLINE: Self = Self(1 << 3);
215 pub const BLINK: Self = Self(1 << 4);
217 pub const REVERSE: Self = Self(1 << 5);
219 pub const HIDDEN: Self = Self(1 << 6);
221 pub const STRIKETHROUGH: Self = Self(1 << 7);
223 pub const DOUBLE_UNDERLINE: Self = Self(1 << 8);
225 pub const CURLY_UNDERLINE: Self = Self(1 << 9);
227
228 #[inline]
230 pub const fn contains(self, other: Self) -> bool {
231 (self.0 & other.0) == other.0
232 }
233
234 #[inline]
236 pub fn insert(&mut self, other: Self) {
237 self.0 |= other.0;
238 }
239
240 #[inline]
242 pub fn remove(&mut self, other: Self) {
243 self.0 &= !other.0;
244 }
245
246 #[inline]
248 pub const fn is_empty(self) -> bool {
249 self.0 == 0
250 }
251
252 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
296pub struct Style {
297 pub fg: Option<PackedRgba>,
299 pub bg: Option<PackedRgba>,
301 pub attrs: Option<StyleFlags>,
303 pub underline_color: Option<PackedRgba>,
305}
306
307impl Style {
308 #[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 #[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 #[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 #[inline]
337 #[must_use]
338 pub fn bold(self) -> Self {
339 self.add_attr(StyleFlags::BOLD)
340 }
341
342 #[inline]
344 #[must_use]
345 pub fn italic(self) -> Self {
346 self.add_attr(StyleFlags::ITALIC)
347 }
348
349 #[inline]
351 #[must_use]
352 pub fn underline(self) -> Self {
353 self.add_attr(StyleFlags::UNDERLINE)
354 }
355
356 #[inline]
358 #[must_use]
359 pub fn dim(self) -> Self {
360 self.add_attr(StyleFlags::DIM)
361 }
362
363 #[inline]
365 #[must_use]
366 pub fn reverse(self) -> Self {
367 self.add_attr(StyleFlags::REVERSE)
368 }
369
370 #[inline]
372 #[must_use]
373 pub fn strikethrough(self) -> Self {
374 self.add_attr(StyleFlags::STRIKETHROUGH)
375 }
376
377 #[inline]
379 #[must_use]
380 pub fn blink(self) -> Self {
381 self.add_attr(StyleFlags::BLINK)
382 }
383
384 #[inline]
386 #[must_use]
387 pub fn hidden(self) -> Self {
388 self.add_attr(StyleFlags::HIDDEN)
389 }
390
391 #[inline]
393 #[must_use]
394 pub fn double_underline(self) -> Self {
395 self.add_attr(StyleFlags::DOUBLE_UNDERLINE)
396 }
397
398 #[inline]
400 #[must_use]
401 pub fn curly_underline(self) -> Self {
402 self.add_attr(StyleFlags::CURLY_UNDERLINE)
403 }
404
405 #[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 #[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 #[inline]
425 #[must_use]
426 pub const fn attrs(mut self, attrs: StyleFlags) -> Self {
427 self.attrs = Some(attrs);
428 self
429 }
430
431 #[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 #[inline]
472 pub fn patch(&self, child: &Style) -> Style {
473 child.merge(self)
474 }
475
476 #[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 #[inline]
487 pub fn has_attr(&self, flag: StyleFlags) -> bool {
488 self.attrs.is_some_and(|a| a.contains(flag))
489 }
490}
491
492impl 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
524impl 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 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)); }
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); let merged = child.merge(&parent);
653
654 assert_eq!(merged.fg, Some(blue)); assert_eq!(merged.bg, Some(white)); }
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)); assert!(merged.has_attr(StyleFlags::ITALIC)); }
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 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 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 use super::*;
926
927 #[test]
928 fn merge_chain_three_styles() {
929 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 let parent_merged = parent.merge(&grandparent);
941 assert_eq!(parent_merged.fg, Some(green)); assert_eq!(parent_merged.bg, Some(white)); assert!(parent_merged.has_attr(StyleFlags::BOLD)); assert!(parent_merged.has_attr(StyleFlags::ITALIC)); let child_merged = child.merge(&parent_merged);
948 assert_eq!(child_merged.fg, Some(blue)); assert_eq!(child_merged.bg, Some(white)); assert!(child_merged.has_attr(StyleFlags::BOLD)); assert!(child_merged.has_attr(StyleFlags::ITALIC)); }
953
954 #[test]
955 fn merge_chain_attrs_accumulate() {
956 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(); 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); assert!(style.has_attr(StyleFlags::UNDERLINE));
1000 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 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 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 .underline_color(PackedRgba::rgb(30, 30, 30))
1031 .italic();
1032
1033 let merged = child.merge(&parent);
1034
1035 assert_eq!(merged.fg, Some(PackedRgba::rgb(10, 10, 10)));
1037 assert_eq!(merged.bg, Some(PackedRgba::rgb(2, 2, 2)));
1039 assert_eq!(merged.underline_color, Some(PackedRgba::rgb(30, 30, 30)));
1041 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; 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); 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 assert!(combined.contains(StyleFlags::BOLD));
1084 assert!(combined.contains(StyleFlags::ITALIC));
1085 assert!(combined.contains(StyleFlags::UNDERLINE));
1086
1087 assert!(combined.contains(StyleFlags::BOLD | StyleFlags::ITALIC));
1089
1090 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); 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 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 #[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 #[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 #[test]
1199 fn overflow_default_is_hidden() {
1200 assert_eq!(Overflow::default(), Overflow::Hidden);
1201 }
1202
1203 #[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 #[test]
1253 fn text_align_default_is_left() {
1254 assert_eq!(TextAlign::default(), TextAlign::Left);
1255 }
1256
1257 #[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}