1#![forbid(unsafe_code)]
2
3use crate::color::{Ansi16, Color, ColorProfile};
6use crate::{Style, StyleFlags};
7use ftui_render::cell::PackedRgba;
8use std::hash::{Hash, Hasher};
9
10#[inline]
11fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
12 let a = a as f32;
13 let b = b as f32;
14 (a + (b - a) * t).round().clamp(0.0, 255.0) as u8
15}
16
17#[inline]
18fn lerp_color(a: PackedRgba, b: PackedRgba, t: f32) -> PackedRgba {
19 let t = t.clamp(0.0, 1.0);
20 PackedRgba::rgba(
21 lerp_u8(a.r(), b.r(), t),
22 lerp_u8(a.g(), b.g(), t),
23 lerp_u8(a.b(), b.b(), t),
24 lerp_u8(a.a(), b.a(), t),
25 )
26}
27
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
31pub enum TablePresetId {
32 Aurora,
34 Graphite,
36 Neon,
38 Slate,
40 Solar,
42 Orchard,
44 Paper,
46 Midnight,
48 TerminalClassic,
50}
51
52#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
55pub enum TableSection {
56 Header,
58 Body,
60 Footer,
62}
63
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
67pub enum TableEffectTarget {
68 Section(TableSection),
70 Row(usize),
72 RowRange { start: usize, end: usize },
74 Column(usize),
76 ColumnRange { start: usize, end: usize },
78 AllRows,
80 AllCells,
82}
83
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
87pub struct TableEffectScope {
88 pub section: TableSection,
90 pub row: Option<usize>,
92 pub column: Option<usize>,
94}
95
96impl TableEffectScope {
97 #[must_use]
99 pub const fn section(section: TableSection) -> Self {
100 Self {
101 section,
102 row: None,
103 column: None,
104 }
105 }
106
107 #[must_use]
109 pub const fn row(section: TableSection, row: usize) -> Self {
110 Self {
111 section,
112 row: Some(row),
113 column: None,
114 }
115 }
116
117 #[must_use]
119 pub const fn column(section: TableSection, column: usize) -> Self {
120 Self {
121 section,
122 row: None,
123 column: Some(column),
124 }
125 }
126}
127
128impl TableEffectTarget {
129 #[must_use]
131 pub fn matches_scope(&self, scope: TableEffectScope) -> bool {
132 match *self {
133 TableEffectTarget::Section(section) => scope.section == section,
134 TableEffectTarget::Row(row) => scope.row == Some(row),
135 TableEffectTarget::RowRange { start, end } => {
136 scope.row.is_some_and(|row| row >= start && row <= end)
137 }
138 TableEffectTarget::Column(column) => scope.column == Some(column),
139 TableEffectTarget::ColumnRange { start, end } => scope
140 .column
141 .is_some_and(|column| column >= start && column <= end),
142 TableEffectTarget::AllRows => {
143 scope.section == TableSection::Body && scope.row.is_some()
144 }
145 TableEffectTarget::AllCells => {
146 matches!(scope.section, TableSection::Header | TableSection::Body)
147 && (scope.row.is_some() || scope.column.is_some())
148 }
149 }
150 }
151}
152
153#[derive(Clone, Debug, PartialEq)]
155pub struct Gradient {
156 stops: Vec<(f32, PackedRgba)>,
157}
158
159impl Gradient {
160 pub fn new(stops: Vec<(f32, PackedRgba)>) -> Self {
162 let mut stops = stops;
163 stops.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
164 Self { stops }
165 }
166
167 #[inline]
169 #[must_use]
170 pub fn stops(&self) -> &[(f32, PackedRgba)] {
171 &self.stops
172 }
173
174 #[must_use]
176 pub fn sample(&self, t: f32) -> PackedRgba {
177 let t = t.clamp(0.0, 1.0);
178 let Some(first) = self.stops.first() else {
179 return PackedRgba::TRANSPARENT;
180 };
181 if t <= first.0 {
182 return first.1;
183 }
184 let Some(last) = self.stops.last() else {
185 return first.1;
186 };
187 if t >= last.0 {
188 return last.1;
189 }
190
191 for window in self.stops.windows(2) {
192 let (p0, c0) = window[0];
193 let (p1, c1) = window[1];
194 if t <= p1 {
195 let denom = p1 - p0;
196 if denom <= f32::EPSILON {
197 return c1;
198 }
199 let local = (t - p0) / denom;
200 return lerp_color(c0, c1, local);
201 }
202 }
203
204 last.1
205 }
206}
207
208#[derive(Clone, Debug)]
210pub enum TableEffect {
211 Pulse {
213 fg_a: PackedRgba,
214 fg_b: PackedRgba,
215 bg_a: PackedRgba,
216 bg_b: PackedRgba,
217 speed: f32,
218 phase_offset: f32,
219 },
220 BreathingGlow {
222 fg: PackedRgba,
223 bg: PackedRgba,
224 intensity: f32,
225 speed: f32,
226 phase_offset: f32,
227 asymmetry: f32,
228 },
229 GradientSweep {
231 gradient: Gradient,
232 speed: f32,
233 phase_offset: f32,
234 },
235}
236
237#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
239#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
240pub enum BlendMode {
241 #[default]
242 Replace,
243 Additive,
244 Multiply,
245 Screen,
246}
247
248#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
250#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
251#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
252pub struct StyleMask {
253 pub fg: bool,
254 pub bg: bool,
255 pub attrs: bool,
256}
257
258impl StyleMask {
259 #[must_use]
261 pub const fn fg_bg() -> Self {
262 Self {
263 fg: true,
264 bg: true,
265 attrs: false,
266 }
267 }
268
269 #[must_use]
271 pub const fn all() -> Self {
272 Self {
273 fg: true,
274 bg: true,
275 attrs: true,
276 }
277 }
278
279 #[must_use]
281 pub const fn none() -> Self {
282 Self {
283 fg: false,
284 bg: false,
285 attrs: false,
286 }
287 }
288}
289
290impl Default for StyleMask {
291 fn default() -> Self {
292 Self::fg_bg()
293 }
294}
295
296#[derive(Clone, Debug)]
298pub struct TableEffectRule {
299 pub target: TableEffectTarget,
301 pub effect: TableEffect,
303 pub priority: u8,
305 pub blend_mode: BlendMode,
307 pub style_mask: StyleMask,
309}
310
311impl TableEffectRule {
312 #[must_use]
314 pub fn new(target: TableEffectTarget, effect: TableEffect) -> Self {
315 Self {
316 target,
317 effect,
318 priority: 0,
319 blend_mode: BlendMode::default(),
320 style_mask: StyleMask::default(),
321 }
322 }
323
324 #[must_use]
326 pub fn priority(mut self, priority: u8) -> Self {
327 self.priority = priority;
328 self
329 }
330
331 #[must_use]
333 pub fn blend_mode(mut self, blend_mode: BlendMode) -> Self {
334 self.blend_mode = blend_mode;
335 self
336 }
337
338 #[must_use]
340 pub fn style_mask(mut self, style_mask: StyleMask) -> Self {
341 self.style_mask = style_mask;
342 self
343 }
344}
345
346pub struct TableEffectResolver<'a> {
350 theme: &'a TableTheme,
351}
352
353impl<'a> TableEffectResolver<'a> {
354 #[must_use]
356 pub const fn new(theme: &'a TableTheme) -> Self {
357 Self { theme }
358 }
359
360 #[must_use]
362 pub fn resolve(&self, base: Style, scope: TableEffectScope, phase: f32) -> Style {
363 resolve_effects_for_scope(self.theme, base, scope, phase)
364 }
365}
366
367#[derive(Clone, Debug)]
418pub struct TableTheme {
419 pub border: Style,
421 pub header: Style,
423 pub row: Style,
425 pub row_alt: Style,
427 pub row_selected: Style,
429 pub row_hover: Style,
431 pub divider: Style,
433 pub padding: u8,
435 pub column_gap: u8,
437 pub row_height: u8,
439 pub effects: Vec<TableEffectRule>,
441 pub preset_id: Option<TablePresetId>,
443}
444
445#[derive(Clone, Debug)]
447pub struct TableThemeDiagnostics {
448 pub preset_id: Option<TablePresetId>,
449 pub style_hash: u64,
450 pub effects_hash: u64,
451 pub effect_count: usize,
452 pub padding: u8,
453 pub column_gap: u8,
454 pub row_height: u8,
455}
456
457#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
466#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
467#[derive(Clone, Debug, PartialEq)]
468pub struct TableThemeSpec {
469 pub version: u8,
471 pub name: Option<String>,
473 pub preset_id: Option<TablePresetId>,
475 pub padding: u8,
477 pub column_gap: u8,
478 pub row_height: u8,
479 pub styles: TableThemeStyleSpec,
481 pub effects: Vec<TableEffectRuleSpec>,
483}
484
485#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
486#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
487#[derive(Clone, Debug, PartialEq)]
488pub struct TableThemeStyleSpec {
489 pub border: StyleSpec,
490 pub header: StyleSpec,
491 pub row: StyleSpec,
492 pub row_alt: StyleSpec,
493 pub row_selected: StyleSpec,
494 pub row_hover: StyleSpec,
495 pub divider: StyleSpec,
496}
497
498#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
499#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
500#[derive(Clone, Debug, PartialEq)]
501pub struct StyleSpec {
502 pub fg: Option<RgbaSpec>,
503 pub bg: Option<RgbaSpec>,
504 pub underline: Option<RgbaSpec>,
505 pub attrs: Vec<StyleAttr>,
506}
507
508#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
509#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
510pub enum StyleAttr {
511 Bold,
512 Dim,
513 Italic,
514 Underline,
515 Blink,
516 Reverse,
517 Hidden,
518 Strikethrough,
519 DoubleUnderline,
520 CurlyUnderline,
521}
522
523#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
524#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
525#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
526pub struct RgbaSpec {
527 pub r: u8,
528 pub g: u8,
529 pub b: u8,
530 pub a: u8,
531}
532
533impl RgbaSpec {
534 #[must_use]
535 pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
536 Self { r, g, b, a }
537 }
538}
539
540impl From<PackedRgba> for RgbaSpec {
541 fn from(color: PackedRgba) -> Self {
542 Self::new(color.r(), color.g(), color.b(), color.a())
543 }
544}
545
546impl From<RgbaSpec> for PackedRgba {
547 fn from(color: RgbaSpec) -> Self {
548 PackedRgba::rgba(color.r, color.g, color.b, color.a)
549 }
550}
551
552#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
553#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
554#[derive(Clone, Debug, PartialEq)]
555pub struct GradientSpec {
556 pub stops: Vec<GradientStopSpec>,
557}
558
559#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
560#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
561#[derive(Clone, Copy, Debug, PartialEq)]
562pub struct GradientStopSpec {
563 pub pos: f32,
564 pub color: RgbaSpec,
565}
566
567#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
568#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
569#[derive(Clone, Debug, PartialEq)]
570pub enum TableEffectSpec {
571 Pulse {
572 fg_a: RgbaSpec,
573 fg_b: RgbaSpec,
574 bg_a: RgbaSpec,
575 bg_b: RgbaSpec,
576 speed: f32,
577 phase_offset: f32,
578 },
579 BreathingGlow {
580 fg: RgbaSpec,
581 bg: RgbaSpec,
582 intensity: f32,
583 speed: f32,
584 phase_offset: f32,
585 asymmetry: f32,
586 },
587 GradientSweep {
588 gradient: GradientSpec,
589 speed: f32,
590 phase_offset: f32,
591 },
592}
593
594#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
595#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
596#[derive(Clone, Debug, PartialEq)]
597pub struct TableEffectRuleSpec {
598 pub target: TableEffectTarget,
599 pub effect: TableEffectSpec,
600 pub priority: u8,
601 pub blend_mode: BlendMode,
602 pub style_mask: StyleMask,
603}
604
605pub const TABLE_THEME_SPEC_VERSION: u8 = 1;
607const TABLE_THEME_SPEC_MAX_NAME_LEN: usize = 64;
608const TABLE_THEME_SPEC_MAX_EFFECTS: usize = 64;
609const TABLE_THEME_SPEC_MAX_STYLE_ATTRS: usize = 16;
610const TABLE_THEME_SPEC_MAX_GRADIENT_STOPS: usize = 16;
611const TABLE_THEME_SPEC_MIN_GRADIENT_STOPS: usize = 1;
612const TABLE_THEME_SPEC_MAX_PADDING: u8 = 8;
613const TABLE_THEME_SPEC_MAX_COLUMN_GAP: u8 = 8;
614const TABLE_THEME_SPEC_MIN_ROW_HEIGHT: u8 = 1;
615const TABLE_THEME_SPEC_MAX_ROW_HEIGHT: u8 = 8;
616const TABLE_THEME_SPEC_MAX_SPEED: f32 = 10.0;
617const TABLE_THEME_SPEC_MAX_PHASE: f32 = 1.0;
618const TABLE_THEME_SPEC_MAX_INTENSITY: f32 = 1.0;
619const TABLE_THEME_SPEC_MAX_ASYMMETRY: f32 = 0.9;
620
621#[derive(Debug, Clone, PartialEq, Eq)]
622pub struct TableThemeSpecError {
623 pub field: String,
624 pub message: String,
625}
626
627impl TableThemeSpecError {
628 fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
629 Self {
630 field: field.into(),
631 message: message.into(),
632 }
633 }
634}
635
636impl std::fmt::Display for TableThemeSpecError {
637 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
638 write!(f, "{}: {}", self.field, self.message)
639 }
640}
641
642impl std::error::Error for TableThemeSpecError {}
643
644impl TableThemeSpec {
645 #[must_use]
647 pub fn from_theme(theme: &TableTheme) -> Self {
648 Self {
649 version: TABLE_THEME_SPEC_VERSION,
650 name: None,
651 preset_id: theme.preset_id,
652 padding: theme.padding,
653 column_gap: theme.column_gap,
654 row_height: theme.row_height,
655 styles: TableThemeStyleSpec {
656 border: StyleSpec::from_style(&theme.border),
657 header: StyleSpec::from_style(&theme.header),
658 row: StyleSpec::from_style(&theme.row),
659 row_alt: StyleSpec::from_style(&theme.row_alt),
660 row_selected: StyleSpec::from_style(&theme.row_selected),
661 row_hover: StyleSpec::from_style(&theme.row_hover),
662 divider: StyleSpec::from_style(&theme.divider),
663 },
664 effects: theme
665 .effects
666 .iter()
667 .map(TableEffectRuleSpec::from_rule)
668 .collect(),
669 }
670 }
671
672 #[must_use]
674 pub fn into_theme(self) -> TableTheme {
675 TableTheme {
676 border: self.styles.border.to_style(),
677 header: self.styles.header.to_style(),
678 row: self.styles.row.to_style(),
679 row_alt: self.styles.row_alt.to_style(),
680 row_selected: self.styles.row_selected.to_style(),
681 row_hover: self.styles.row_hover.to_style(),
682 divider: self.styles.divider.to_style(),
683 padding: self.padding,
684 column_gap: self.column_gap,
685 row_height: self.row_height,
686 effects: self
687 .effects
688 .into_iter()
689 .map(|spec| spec.to_rule())
690 .collect(),
691 preset_id: self.preset_id,
692 }
693 }
694
695 pub fn validate(&self) -> Result<(), TableThemeSpecError> {
697 if self.version != TABLE_THEME_SPEC_VERSION {
698 return Err(TableThemeSpecError::new(
699 "version",
700 format!("unsupported version {}", self.version),
701 ));
702 }
703
704 if let Some(name) = &self.name
705 && name.len() > TABLE_THEME_SPEC_MAX_NAME_LEN
706 {
707 return Err(TableThemeSpecError::new(
708 "name",
709 format!(
710 "name length {} exceeds max {}",
711 name.len(),
712 TABLE_THEME_SPEC_MAX_NAME_LEN
713 ),
714 ));
715 }
716
717 validate_u8_range("padding", self.padding, 0, TABLE_THEME_SPEC_MAX_PADDING)?;
718 validate_u8_range(
719 "column_gap",
720 self.column_gap,
721 0,
722 TABLE_THEME_SPEC_MAX_COLUMN_GAP,
723 )?;
724 validate_u8_range(
725 "row_height",
726 self.row_height,
727 TABLE_THEME_SPEC_MIN_ROW_HEIGHT,
728 TABLE_THEME_SPEC_MAX_ROW_HEIGHT,
729 )?;
730
731 validate_style_spec(&self.styles.border, "styles.border")?;
732 validate_style_spec(&self.styles.header, "styles.header")?;
733 validate_style_spec(&self.styles.row, "styles.row")?;
734 validate_style_spec(&self.styles.row_alt, "styles.row_alt")?;
735 validate_style_spec(&self.styles.row_selected, "styles.row_selected")?;
736 validate_style_spec(&self.styles.row_hover, "styles.row_hover")?;
737 validate_style_spec(&self.styles.divider, "styles.divider")?;
738
739 if self.effects.len() > TABLE_THEME_SPEC_MAX_EFFECTS {
740 return Err(TableThemeSpecError::new(
741 "effects",
742 format!(
743 "effect count {} exceeds max {}",
744 self.effects.len(),
745 TABLE_THEME_SPEC_MAX_EFFECTS
746 ),
747 ));
748 }
749
750 for (idx, rule) in self.effects.iter().enumerate() {
751 validate_effect_rule(rule, idx)?;
752 }
753
754 Ok(())
755 }
756}
757
758fn validate_u8_range(
759 field: impl Into<String>,
760 value: u8,
761 min: u8,
762 max: u8,
763) -> Result<(), TableThemeSpecError> {
764 if value < min || value > max {
765 return Err(TableThemeSpecError::new(
766 field,
767 format!("value {} outside range [{}..={}]", value, min, max),
768 ));
769 }
770 Ok(())
771}
772
773fn validate_style_spec(style: &StyleSpec, field: &str) -> Result<(), TableThemeSpecError> {
774 if style.attrs.len() > TABLE_THEME_SPEC_MAX_STYLE_ATTRS {
775 return Err(TableThemeSpecError::new(
776 format!("{field}.attrs"),
777 format!(
778 "attr count {} exceeds max {}",
779 style.attrs.len(),
780 TABLE_THEME_SPEC_MAX_STYLE_ATTRS
781 ),
782 ));
783 }
784 Ok(())
785}
786
787fn validate_effect_target(
788 target: &TableEffectTarget,
789 idx: usize,
790) -> Result<(), TableThemeSpecError> {
791 let base = format!("effects[{idx}].target");
792 match *target {
793 TableEffectTarget::RowRange { start, end } if start > end => {
794 return Err(TableThemeSpecError::new(
795 format!("{base}.row_range"),
796 "start must be <= end",
797 ));
798 }
799 TableEffectTarget::ColumnRange { start, end } if start > end => {
800 return Err(TableThemeSpecError::new(
801 format!("{base}.column_range"),
802 "start must be <= end",
803 ));
804 }
805 _ => {}
806 }
807 Ok(())
808}
809
810fn validate_effect_rule(rule: &TableEffectRuleSpec, idx: usize) -> Result<(), TableThemeSpecError> {
811 validate_effect_target(&rule.target, idx)?;
812 let base = format!("effects[{idx}].effect");
813 match &rule.effect {
814 TableEffectSpec::Pulse {
815 speed,
816 phase_offset,
817 ..
818 } => {
819 validate_f32_range(
820 format!("{base}.speed"),
821 *speed,
822 0.0,
823 TABLE_THEME_SPEC_MAX_SPEED,
824 )?;
825 validate_f32_range(
826 format!("{base}.phase_offset"),
827 *phase_offset,
828 0.0,
829 TABLE_THEME_SPEC_MAX_PHASE,
830 )?;
831 }
832 TableEffectSpec::BreathingGlow {
833 intensity,
834 speed,
835 phase_offset,
836 asymmetry,
837 ..
838 } => {
839 validate_f32_range(
840 format!("{base}.intensity"),
841 *intensity,
842 0.0,
843 TABLE_THEME_SPEC_MAX_INTENSITY,
844 )?;
845 validate_f32_range(
846 format!("{base}.speed"),
847 *speed,
848 0.0,
849 TABLE_THEME_SPEC_MAX_SPEED,
850 )?;
851 validate_f32_range(
852 format!("{base}.phase_offset"),
853 *phase_offset,
854 0.0,
855 TABLE_THEME_SPEC_MAX_PHASE,
856 )?;
857 validate_f32_range(
858 format!("{base}.asymmetry"),
859 *asymmetry,
860 -TABLE_THEME_SPEC_MAX_ASYMMETRY,
861 TABLE_THEME_SPEC_MAX_ASYMMETRY,
862 )?;
863 }
864 TableEffectSpec::GradientSweep {
865 gradient,
866 speed,
867 phase_offset,
868 } => {
869 validate_gradient_spec(gradient, &base)?;
870 validate_f32_range(
871 format!("{base}.speed"),
872 *speed,
873 0.0,
874 TABLE_THEME_SPEC_MAX_SPEED,
875 )?;
876 validate_f32_range(
877 format!("{base}.phase_offset"),
878 *phase_offset,
879 0.0,
880 TABLE_THEME_SPEC_MAX_PHASE,
881 )?;
882 }
883 }
884 Ok(())
885}
886
887fn validate_gradient_spec(gradient: &GradientSpec, base: &str) -> Result<(), TableThemeSpecError> {
888 let count = gradient.stops.len();
889 if !(TABLE_THEME_SPEC_MIN_GRADIENT_STOPS..=TABLE_THEME_SPEC_MAX_GRADIENT_STOPS).contains(&count)
890 {
891 return Err(TableThemeSpecError::new(
892 format!("{base}.gradient.stops"),
893 format!(
894 "stop count {} outside range [{}..={}]",
895 count, TABLE_THEME_SPEC_MIN_GRADIENT_STOPS, TABLE_THEME_SPEC_MAX_GRADIENT_STOPS
896 ),
897 ));
898 }
899 for (idx, stop) in gradient.stops.iter().enumerate() {
900 validate_f32_range(
901 format!("{base}.gradient.stops[{idx}].pos"),
902 stop.pos,
903 0.0,
904 1.0,
905 )?;
906 }
907 Ok(())
908}
909
910fn validate_f32_range(
911 field: impl Into<String>,
912 value: f32,
913 min: f32,
914 max: f32,
915) -> Result<(), TableThemeSpecError> {
916 if !value.is_finite() {
917 return Err(TableThemeSpecError::new(field, "value must be finite"));
918 }
919 if value < min || value > max {
920 return Err(TableThemeSpecError::new(
921 field,
922 format!("value {} outside range [{min}..={max}]", value),
923 ));
924 }
925 Ok(())
926}
927
928impl StyleSpec {
929 #[must_use]
930 pub fn from_style(style: &Style) -> Self {
931 Self {
932 fg: style.fg.map(RgbaSpec::from),
933 bg: style.bg.map(RgbaSpec::from),
934 underline: style.underline_color.map(RgbaSpec::from),
935 attrs: style.attrs.map(attrs_from_flags).unwrap_or_default(),
936 }
937 }
938
939 #[must_use]
940 pub fn to_style(&self) -> Style {
941 let mut style = Style::new();
942 style.fg = self.fg.map(PackedRgba::from);
943 style.bg = self.bg.map(PackedRgba::from);
944 style.underline_color = self.underline.map(PackedRgba::from);
945 style.attrs = flags_from_attrs(&self.attrs);
946 style
947 }
948}
949
950impl GradientSpec {
951 #[must_use]
952 pub fn from_gradient(gradient: &Gradient) -> Self {
953 Self {
954 stops: gradient
955 .stops()
956 .iter()
957 .map(|(pos, color)| GradientStopSpec {
958 pos: *pos,
959 color: RgbaSpec::from(*color),
960 })
961 .collect(),
962 }
963 }
964
965 #[must_use]
966 pub fn to_gradient(&self) -> Gradient {
967 Gradient::new(
968 self.stops
969 .iter()
970 .map(|stop| (stop.pos, PackedRgba::from(stop.color)))
971 .collect(),
972 )
973 }
974}
975
976impl TableEffectSpec {
977 #[must_use]
978 pub fn from_effect(effect: &TableEffect) -> Self {
979 match effect {
980 TableEffect::Pulse {
981 fg_a,
982 fg_b,
983 bg_a,
984 bg_b,
985 speed,
986 phase_offset,
987 } => Self::Pulse {
988 fg_a: (*fg_a).into(),
989 fg_b: (*fg_b).into(),
990 bg_a: (*bg_a).into(),
991 bg_b: (*bg_b).into(),
992 speed: *speed,
993 phase_offset: *phase_offset,
994 },
995 TableEffect::BreathingGlow {
996 fg,
997 bg,
998 intensity,
999 speed,
1000 phase_offset,
1001 asymmetry,
1002 } => Self::BreathingGlow {
1003 fg: (*fg).into(),
1004 bg: (*bg).into(),
1005 intensity: *intensity,
1006 speed: *speed,
1007 phase_offset: *phase_offset,
1008 asymmetry: *asymmetry,
1009 },
1010 TableEffect::GradientSweep {
1011 gradient,
1012 speed,
1013 phase_offset,
1014 } => Self::GradientSweep {
1015 gradient: GradientSpec::from_gradient(gradient),
1016 speed: *speed,
1017 phase_offset: *phase_offset,
1018 },
1019 }
1020 }
1021
1022 #[must_use]
1023 pub fn to_effect(&self) -> TableEffect {
1024 match self {
1025 TableEffectSpec::Pulse {
1026 fg_a,
1027 fg_b,
1028 bg_a,
1029 bg_b,
1030 speed,
1031 phase_offset,
1032 } => TableEffect::Pulse {
1033 fg_a: (*fg_a).into(),
1034 fg_b: (*fg_b).into(),
1035 bg_a: (*bg_a).into(),
1036 bg_b: (*bg_b).into(),
1037 speed: *speed,
1038 phase_offset: *phase_offset,
1039 },
1040 TableEffectSpec::BreathingGlow {
1041 fg,
1042 bg,
1043 intensity,
1044 speed,
1045 phase_offset,
1046 asymmetry,
1047 } => TableEffect::BreathingGlow {
1048 fg: (*fg).into(),
1049 bg: (*bg).into(),
1050 intensity: *intensity,
1051 speed: *speed,
1052 phase_offset: *phase_offset,
1053 asymmetry: *asymmetry,
1054 },
1055 TableEffectSpec::GradientSweep {
1056 gradient,
1057 speed,
1058 phase_offset,
1059 } => TableEffect::GradientSweep {
1060 gradient: gradient.to_gradient(),
1061 speed: *speed,
1062 phase_offset: *phase_offset,
1063 },
1064 }
1065 }
1066}
1067
1068impl TableEffectRuleSpec {
1069 #[must_use]
1070 pub fn from_rule(rule: &TableEffectRule) -> Self {
1071 Self {
1072 target: rule.target,
1073 effect: TableEffectSpec::from_effect(&rule.effect),
1074 priority: rule.priority,
1075 blend_mode: rule.blend_mode,
1076 style_mask: rule.style_mask,
1077 }
1078 }
1079
1080 #[must_use]
1081 pub fn to_rule(&self) -> TableEffectRule {
1082 TableEffectRule {
1083 target: self.target,
1084 effect: self.effect.to_effect(),
1085 priority: self.priority,
1086 blend_mode: self.blend_mode,
1087 style_mask: self.style_mask,
1088 }
1089 }
1090}
1091
1092fn attrs_from_flags(flags: StyleFlags) -> Vec<StyleAttr> {
1093 let mut attrs = Vec::new();
1094 if flags.contains(StyleFlags::BOLD) {
1095 attrs.push(StyleAttr::Bold);
1096 }
1097 if flags.contains(StyleFlags::DIM) {
1098 attrs.push(StyleAttr::Dim);
1099 }
1100 if flags.contains(StyleFlags::ITALIC) {
1101 attrs.push(StyleAttr::Italic);
1102 }
1103 if flags.contains(StyleFlags::UNDERLINE) {
1104 attrs.push(StyleAttr::Underline);
1105 }
1106 if flags.contains(StyleFlags::BLINK) {
1107 attrs.push(StyleAttr::Blink);
1108 }
1109 if flags.contains(StyleFlags::REVERSE) {
1110 attrs.push(StyleAttr::Reverse);
1111 }
1112 if flags.contains(StyleFlags::HIDDEN) {
1113 attrs.push(StyleAttr::Hidden);
1114 }
1115 if flags.contains(StyleFlags::STRIKETHROUGH) {
1116 attrs.push(StyleAttr::Strikethrough);
1117 }
1118 if flags.contains(StyleFlags::DOUBLE_UNDERLINE) {
1119 attrs.push(StyleAttr::DoubleUnderline);
1120 }
1121 if flags.contains(StyleFlags::CURLY_UNDERLINE) {
1122 attrs.push(StyleAttr::CurlyUnderline);
1123 }
1124 attrs
1125}
1126
1127fn flags_from_attrs(attrs: &[StyleAttr]) -> Option<StyleFlags> {
1128 if attrs.is_empty() {
1129 return None;
1130 }
1131 let mut flags = StyleFlags::NONE;
1132 for attr in attrs {
1133 match attr {
1134 StyleAttr::Bold => flags.insert(StyleFlags::BOLD),
1135 StyleAttr::Dim => flags.insert(StyleFlags::DIM),
1136 StyleAttr::Italic => flags.insert(StyleFlags::ITALIC),
1137 StyleAttr::Underline => flags.insert(StyleFlags::UNDERLINE),
1138 StyleAttr::Blink => flags.insert(StyleFlags::BLINK),
1139 StyleAttr::Reverse => flags.insert(StyleFlags::REVERSE),
1140 StyleAttr::Hidden => flags.insert(StyleFlags::HIDDEN),
1141 StyleAttr::Strikethrough => flags.insert(StyleFlags::STRIKETHROUGH),
1142 StyleAttr::DoubleUnderline => flags.insert(StyleFlags::DOUBLE_UNDERLINE),
1143 StyleAttr::CurlyUnderline => flags.insert(StyleFlags::CURLY_UNDERLINE),
1144 }
1145 }
1146 if flags.is_empty() { None } else { Some(flags) }
1147}
1148
1149struct ThemeStyles {
1150 border: Style,
1151 header: Style,
1152 row: Style,
1153 row_alt: Style,
1154 row_selected: Style,
1155 row_hover: Style,
1156 divider: Style,
1157}
1158
1159impl TableTheme {
1160 #[must_use]
1162 pub const fn effect_resolver(&self) -> TableEffectResolver<'_> {
1163 TableEffectResolver::new(self)
1164 }
1165
1166 #[must_use]
1168 pub fn preset(preset: TablePresetId) -> Self {
1169 match preset {
1170 TablePresetId::Aurora => Self::aurora(),
1171 TablePresetId::Graphite => Self::graphite(),
1172 TablePresetId::Neon => Self::neon(),
1173 TablePresetId::Slate => Self::slate(),
1174 TablePresetId::Solar => Self::solar(),
1175 TablePresetId::Orchard => Self::orchard(),
1176 TablePresetId::Paper => Self::paper(),
1177 TablePresetId::Midnight => Self::midnight(),
1178 TablePresetId::TerminalClassic => Self::terminal_classic(),
1179 }
1180 }
1181
1182 #[must_use]
1184 pub fn with_border(mut self, border: Style) -> Self {
1185 self.border = border;
1186 self
1187 }
1188
1189 #[must_use]
1191 pub fn with_header(mut self, header: Style) -> Self {
1192 self.header = header;
1193 self
1194 }
1195
1196 #[must_use]
1198 pub fn with_row(mut self, row: Style) -> Self {
1199 self.row = row;
1200 self
1201 }
1202
1203 #[must_use]
1205 pub fn with_row_alt(mut self, row_alt: Style) -> Self {
1206 self.row_alt = row_alt;
1207 self
1208 }
1209
1210 #[must_use]
1212 pub fn with_row_selected(mut self, row_selected: Style) -> Self {
1213 self.row_selected = row_selected;
1214 self
1215 }
1216
1217 #[must_use]
1219 pub fn with_row_hover(mut self, row_hover: Style) -> Self {
1220 self.row_hover = row_hover;
1221 self
1222 }
1223
1224 #[must_use]
1226 pub fn with_divider(mut self, divider: Style) -> Self {
1227 self.divider = divider;
1228 self
1229 }
1230
1231 #[must_use]
1233 pub fn with_padding(mut self, padding: u8) -> Self {
1234 self.padding = padding;
1235 self
1236 }
1237
1238 #[must_use]
1240 pub fn with_column_gap(mut self, column_gap: u8) -> Self {
1241 self.column_gap = column_gap;
1242 self
1243 }
1244
1245 #[must_use]
1247 pub fn with_row_height(mut self, row_height: u8) -> Self {
1248 self.row_height = row_height;
1249 self
1250 }
1251
1252 #[must_use]
1254 pub fn with_effects(mut self, effects: Vec<TableEffectRule>) -> Self {
1255 self.effects = effects;
1256 self
1257 }
1258
1259 #[must_use]
1261 pub fn with_effect(mut self, effect: TableEffectRule) -> Self {
1262 self.effects.push(effect);
1263 self
1264 }
1265
1266 #[must_use]
1268 pub fn clear_effects(mut self) -> Self {
1269 self.effects.clear();
1270 self
1271 }
1272
1273 #[must_use]
1275 pub fn with_preset_id(mut self, preset_id: Option<TablePresetId>) -> Self {
1276 self.preset_id = preset_id;
1277 self
1278 }
1279
1280 #[must_use]
1282 pub fn aurora() -> Self {
1283 Self::build(
1284 TablePresetId::Aurora,
1285 ThemeStyles {
1286 border: Style::new().fg(PackedRgba::rgb(130, 170, 210)),
1287 header: Style::new()
1288 .fg(PackedRgba::rgb(250, 250, 255))
1289 .bg(PackedRgba::rgb(70, 100, 140))
1290 .bold(),
1291 row: Style::new().fg(PackedRgba::rgb(230, 235, 245)),
1292 row_alt: Style::new()
1293 .fg(PackedRgba::rgb(230, 235, 245))
1294 .bg(PackedRgba::rgb(28, 36, 54)),
1295 row_selected: Style::new()
1296 .fg(PackedRgba::rgb(255, 255, 255))
1297 .bg(PackedRgba::rgb(50, 90, 140))
1298 .bold(),
1299 row_hover: Style::new()
1300 .fg(PackedRgba::rgb(240, 245, 255))
1301 .bg(PackedRgba::rgb(40, 70, 110)),
1302 divider: Style::new().fg(PackedRgba::rgb(90, 120, 160)),
1303 },
1304 )
1305 }
1306
1307 #[must_use]
1309 pub fn graphite() -> Self {
1310 Self::build(
1311 TablePresetId::Graphite,
1312 ThemeStyles {
1313 border: Style::new().fg(PackedRgba::rgb(140, 140, 140)),
1314 header: Style::new()
1315 .fg(PackedRgba::rgb(240, 240, 240))
1316 .bg(PackedRgba::rgb(70, 70, 70))
1317 .bold(),
1318 row: Style::new().fg(PackedRgba::rgb(220, 220, 220)),
1319 row_alt: Style::new()
1320 .fg(PackedRgba::rgb(220, 220, 220))
1321 .bg(PackedRgba::rgb(35, 35, 35)),
1322 row_selected: Style::new()
1323 .fg(PackedRgba::rgb(255, 255, 255))
1324 .bg(PackedRgba::rgb(90, 90, 90)),
1325 row_hover: Style::new()
1326 .fg(PackedRgba::rgb(245, 245, 245))
1327 .bg(PackedRgba::rgb(60, 60, 60)),
1328 divider: Style::new().fg(PackedRgba::rgb(120, 120, 120)),
1329 },
1330 )
1331 }
1332
1333 #[must_use]
1335 pub fn neon() -> Self {
1336 Self::build(
1337 TablePresetId::Neon,
1338 ThemeStyles {
1339 border: Style::new().fg(PackedRgba::rgb(120, 255, 230)),
1340 header: Style::new()
1341 .fg(PackedRgba::rgb(10, 10, 15))
1342 .bg(PackedRgba::rgb(0, 255, 200))
1343 .bold(),
1344 row: Style::new().fg(PackedRgba::rgb(210, 255, 245)),
1345 row_alt: Style::new()
1346 .fg(PackedRgba::rgb(210, 255, 245))
1347 .bg(PackedRgba::rgb(10, 20, 30)),
1348 row_selected: Style::new()
1349 .fg(PackedRgba::rgb(5, 5, 10))
1350 .bg(PackedRgba::rgb(255, 0, 200))
1351 .bold(),
1352 row_hover: Style::new()
1353 .fg(PackedRgba::rgb(0, 10, 15))
1354 .bg(PackedRgba::rgb(0, 200, 255)),
1355 divider: Style::new().fg(PackedRgba::rgb(80, 220, 200)),
1356 },
1357 )
1358 }
1359
1360 #[must_use]
1362 pub fn slate() -> Self {
1363 Self::build(
1364 TablePresetId::Slate,
1365 ThemeStyles {
1366 border: Style::new().fg(PackedRgba::rgb(120, 130, 140)),
1367 header: Style::new()
1368 .fg(PackedRgba::rgb(230, 235, 240))
1369 .bg(PackedRgba::rgb(60, 70, 80))
1370 .bold(),
1371 row: Style::new().fg(PackedRgba::rgb(210, 215, 220)),
1372 row_alt: Style::new()
1373 .fg(PackedRgba::rgb(210, 215, 220))
1374 .bg(PackedRgba::rgb(30, 35, 40)),
1375 row_selected: Style::new()
1376 .fg(PackedRgba::rgb(255, 255, 255))
1377 .bg(PackedRgba::rgb(80, 90, 110)),
1378 row_hover: Style::new()
1379 .fg(PackedRgba::rgb(235, 240, 245))
1380 .bg(PackedRgba::rgb(50, 60, 70)),
1381 divider: Style::new().fg(PackedRgba::rgb(110, 120, 130)),
1382 },
1383 )
1384 }
1385
1386 #[must_use]
1388 pub fn solar() -> Self {
1389 Self::build(
1390 TablePresetId::Solar,
1391 ThemeStyles {
1392 border: Style::new().fg(PackedRgba::rgb(200, 170, 120)),
1393 header: Style::new()
1394 .fg(PackedRgba::rgb(30, 25, 10))
1395 .bg(PackedRgba::rgb(255, 200, 90))
1396 .bold(),
1397 row: Style::new().fg(PackedRgba::rgb(240, 220, 180)),
1398 row_alt: Style::new()
1399 .fg(PackedRgba::rgb(240, 220, 180))
1400 .bg(PackedRgba::rgb(60, 40, 20)),
1401 row_selected: Style::new()
1402 .fg(PackedRgba::rgb(20, 10, 0))
1403 .bg(PackedRgba::rgb(255, 140, 60))
1404 .bold(),
1405 row_hover: Style::new()
1406 .fg(PackedRgba::rgb(20, 10, 0))
1407 .bg(PackedRgba::rgb(220, 120, 40)),
1408 divider: Style::new().fg(PackedRgba::rgb(170, 140, 90)),
1409 },
1410 )
1411 }
1412
1413 #[must_use]
1415 pub fn orchard() -> Self {
1416 Self::build(
1417 TablePresetId::Orchard,
1418 ThemeStyles {
1419 border: Style::new().fg(PackedRgba::rgb(140, 180, 120)),
1420 header: Style::new()
1421 .fg(PackedRgba::rgb(20, 40, 20))
1422 .bg(PackedRgba::rgb(120, 200, 120))
1423 .bold(),
1424 row: Style::new().fg(PackedRgba::rgb(210, 235, 210)),
1425 row_alt: Style::new()
1426 .fg(PackedRgba::rgb(210, 235, 210))
1427 .bg(PackedRgba::rgb(30, 60, 40)),
1428 row_selected: Style::new()
1429 .fg(PackedRgba::rgb(15, 30, 15))
1430 .bg(PackedRgba::rgb(160, 230, 140))
1431 .bold(),
1432 row_hover: Style::new()
1433 .fg(PackedRgba::rgb(15, 30, 15))
1434 .bg(PackedRgba::rgb(130, 210, 120)),
1435 divider: Style::new().fg(PackedRgba::rgb(100, 150, 100)),
1436 },
1437 )
1438 }
1439
1440 #[must_use]
1442 pub fn paper() -> Self {
1443 Self::build(
1444 TablePresetId::Paper,
1445 ThemeStyles {
1446 border: Style::new().fg(PackedRgba::rgb(120, 110, 100)),
1447 header: Style::new()
1448 .fg(PackedRgba::rgb(30, 30, 30))
1449 .bg(PackedRgba::rgb(230, 220, 200))
1450 .bold(),
1451 row: Style::new()
1452 .fg(PackedRgba::rgb(40, 40, 40))
1453 .bg(PackedRgba::rgb(245, 240, 230)),
1454 row_alt: Style::new()
1455 .fg(PackedRgba::rgb(40, 40, 40))
1456 .bg(PackedRgba::rgb(235, 230, 220)),
1457 row_selected: Style::new()
1458 .fg(PackedRgba::rgb(10, 10, 10))
1459 .bg(PackedRgba::rgb(255, 245, 210))
1460 .bold(),
1461 row_hover: Style::new()
1462 .fg(PackedRgba::rgb(20, 20, 20))
1463 .bg(PackedRgba::rgb(245, 235, 205)),
1464 divider: Style::new().fg(PackedRgba::rgb(140, 130, 120)),
1465 },
1466 )
1467 }
1468
1469 #[must_use]
1471 pub fn midnight() -> Self {
1472 Self::build(
1473 TablePresetId::Midnight,
1474 ThemeStyles {
1475 border: Style::new().fg(PackedRgba::rgb(80, 100, 130)),
1476 header: Style::new()
1477 .fg(PackedRgba::rgb(220, 230, 255))
1478 .bg(PackedRgba::rgb(30, 40, 70))
1479 .bold(),
1480 row: Style::new().fg(PackedRgba::rgb(200, 210, 230)),
1481 row_alt: Style::new()
1482 .fg(PackedRgba::rgb(200, 210, 230))
1483 .bg(PackedRgba::rgb(15, 20, 35)),
1484 row_selected: Style::new()
1485 .fg(PackedRgba::rgb(255, 255, 255))
1486 .bg(PackedRgba::rgb(60, 80, 120))
1487 .bold(),
1488 row_hover: Style::new()
1489 .fg(PackedRgba::rgb(240, 240, 255))
1490 .bg(PackedRgba::rgb(45, 60, 90)),
1491 divider: Style::new().fg(PackedRgba::rgb(100, 120, 150)),
1492 },
1493 )
1494 }
1495
1496 #[must_use]
1498 pub fn terminal_classic() -> Self {
1499 Self::terminal_classic_for(ColorProfile::detect())
1500 }
1501
1502 #[must_use]
1504 pub fn terminal_classic_for(profile: ColorProfile) -> Self {
1505 let border = classic_color(profile, (160, 160, 160), Ansi16::BrightBlack);
1506 let header_fg = classic_color(profile, (245, 245, 245), Ansi16::BrightWhite);
1507 let header_bg = classic_color(profile, (0, 90, 140), Ansi16::Blue);
1508 let row_fg = classic_color(profile, (230, 230, 230), Ansi16::White);
1509 let row_alt_bg = classic_color(profile, (30, 30, 30), Ansi16::Black);
1510 let selected_bg = classic_color(profile, (160, 90, 10), Ansi16::Yellow);
1511 let hover_bg = classic_color(profile, (70, 70, 70), Ansi16::BrightBlack);
1512 let divider = classic_color(profile, (120, 120, 120), Ansi16::BrightBlack);
1513
1514 Self::build(
1515 TablePresetId::TerminalClassic,
1516 ThemeStyles {
1517 border: Style::new().fg(border),
1518 header: Style::new().fg(header_fg).bg(header_bg).bold(),
1519 row: Style::new().fg(row_fg),
1520 row_alt: Style::new().fg(row_fg).bg(row_alt_bg),
1521 row_selected: Style::new().fg(PackedRgba::BLACK).bg(selected_bg).bold(),
1522 row_hover: Style::new().fg(PackedRgba::WHITE).bg(hover_bg),
1523 divider: Style::new().fg(divider),
1524 },
1525 )
1526 }
1527
1528 fn build(preset_id: TablePresetId, styles: ThemeStyles) -> Self {
1529 Self {
1530 border: styles.border,
1531 header: styles.header,
1532 row: styles.row,
1533 row_alt: styles.row_alt,
1534 row_selected: styles.row_selected,
1535 row_hover: styles.row_hover,
1536 divider: styles.divider,
1537 padding: 1,
1538 column_gap: 1,
1539 row_height: 1,
1540 effects: Vec::new(),
1541 preset_id: Some(preset_id),
1542 }
1543 }
1544
1545 #[must_use]
1547 pub fn diagnostics(&self) -> TableThemeDiagnostics {
1548 TableThemeDiagnostics {
1549 preset_id: self.preset_id,
1550 style_hash: self.style_hash(),
1551 effects_hash: self.effects_hash(),
1552 effect_count: self.effects.len(),
1553 padding: self.padding,
1554 column_gap: self.column_gap,
1555 row_height: self.row_height,
1556 }
1557 }
1558
1559 #[must_use]
1561 pub fn style_hash(&self) -> u64 {
1562 let mut hasher = StableHasher::new();
1563 hash_style(&self.border, &mut hasher);
1564 hash_style(&self.header, &mut hasher);
1565 hash_style(&self.row, &mut hasher);
1566 hash_style(&self.row_alt, &mut hasher);
1567 hash_style(&self.row_selected, &mut hasher);
1568 hash_style(&self.row_hover, &mut hasher);
1569 hash_style(&self.divider, &mut hasher);
1570 hash_u8(self.padding, &mut hasher);
1571 hash_u8(self.column_gap, &mut hasher);
1572 hash_u8(self.row_height, &mut hasher);
1573 hash_preset(self.preset_id, &mut hasher);
1574 hasher.finish()
1575 }
1576
1577 #[must_use]
1579 pub fn effects_hash(&self) -> u64 {
1580 let mut hasher = StableHasher::new();
1581 hash_usize(self.effects.len(), &mut hasher);
1582 for rule in &self.effects {
1583 hash_effect_rule(rule, &mut hasher);
1584 }
1585 hasher.finish()
1586 }
1587}
1588
1589#[derive(Clone, Copy, Debug)]
1590struct EffectSample {
1591 fg: Option<PackedRgba>,
1592 bg: Option<PackedRgba>,
1593 alpha: f32,
1594}
1595
1596#[inline]
1597fn resolve_effects_for_scope(
1598 theme: &TableTheme,
1599 base: Style,
1600 scope: TableEffectScope,
1601 phase: f32,
1602) -> Style {
1603 if theme.effects.is_empty() {
1604 return base;
1605 }
1606
1607 let mut min_priority = u8::MAX;
1608 let mut max_priority = 0;
1609 for rule in &theme.effects {
1610 min_priority = min_priority.min(rule.priority);
1611 max_priority = max_priority.max(rule.priority);
1612 }
1613 if min_priority == u8::MAX {
1614 return base;
1615 }
1616
1617 let mut resolved = base;
1618 for priority in min_priority..=max_priority {
1619 for rule in &theme.effects {
1620 if rule.priority != priority {
1621 continue;
1622 }
1623 if !rule.target.matches_scope(scope) {
1624 continue;
1625 }
1626 resolved = apply_effect_rule(resolved, rule, phase);
1627 }
1628 }
1629
1630 resolved
1631}
1632
1633#[inline]
1634fn apply_effect_rule(mut base: Style, rule: &TableEffectRule, phase: f32) -> Style {
1635 let sample = sample_effect(&rule.effect, phase);
1636 let alpha = sample.alpha.clamp(0.0, 1.0);
1637 if alpha <= 0.0 {
1638 return base;
1639 }
1640
1641 if rule.style_mask.fg {
1642 base.fg = apply_channel(base.fg, sample.fg, alpha, rule.blend_mode);
1643 }
1644 if rule.style_mask.bg {
1645 base.bg = apply_channel(base.bg, sample.bg, alpha, rule.blend_mode);
1646 }
1647 base
1648}
1649
1650#[inline]
1651fn apply_channel(
1652 base: Option<PackedRgba>,
1653 effect: Option<PackedRgba>,
1654 alpha: f32,
1655 blend_mode: BlendMode,
1656) -> Option<PackedRgba> {
1657 let effect = effect?;
1658 let alpha = alpha.clamp(0.0, 1.0);
1659 let result = match base {
1660 Some(base) => blend_with_alpha(base, effect, alpha, blend_mode),
1661 None => with_alpha(effect, alpha),
1662 };
1663 Some(result)
1664}
1665
1666#[inline]
1667fn blend_with_alpha(
1668 base: PackedRgba,
1669 effect: PackedRgba,
1670 alpha: f32,
1671 blend_mode: BlendMode,
1672) -> PackedRgba {
1673 let alpha = alpha.clamp(0.0, 1.0);
1674 match blend_mode {
1675 BlendMode::Replace => lerp_color(base, effect, alpha),
1676 BlendMode::Additive => blend_additive(with_alpha(effect, alpha), base),
1677 BlendMode::Multiply => blend_multiply(with_alpha(effect, alpha), base),
1678 BlendMode::Screen => blend_screen(with_alpha(effect, alpha), base),
1679 }
1680}
1681
1682#[inline]
1683fn sample_effect(effect: &TableEffect, phase: f32) -> EffectSample {
1684 match *effect {
1685 TableEffect::Pulse {
1686 fg_a,
1687 fg_b,
1688 bg_a,
1689 bg_b,
1690 speed,
1691 phase_offset,
1692 } => {
1693 let t = normalize_phase(phase * speed + phase_offset);
1694 let alpha = pulse_curve(t);
1695 EffectSample {
1696 fg: Some(lerp_color(fg_a, fg_b, alpha)),
1697 bg: Some(lerp_color(bg_a, bg_b, alpha)),
1698 alpha: 1.0,
1699 }
1700 }
1701 TableEffect::BreathingGlow {
1702 fg,
1703 bg,
1704 intensity,
1705 speed,
1706 phase_offset,
1707 asymmetry,
1708 } => {
1709 let t = normalize_phase(phase * speed + phase_offset);
1710 let alpha = (breathing_curve(t, asymmetry) * intensity).clamp(0.0, 1.0);
1711 EffectSample {
1712 fg: Some(fg),
1713 bg: Some(bg),
1714 alpha,
1715 }
1716 }
1717 TableEffect::GradientSweep {
1718 ref gradient,
1719 speed,
1720 phase_offset,
1721 } => {
1722 let t = normalize_phase(phase * speed + phase_offset);
1723 let color = gradient.sample(t);
1724 EffectSample {
1725 fg: Some(color),
1726 bg: Some(color),
1727 alpha: 1.0,
1728 }
1729 }
1730 }
1731}
1732
1733#[inline]
1734fn normalize_phase(phase: f32) -> f32 {
1735 phase.rem_euclid(1.0)
1736}
1737
1738#[inline]
1739fn pulse_curve(t: f32) -> f32 {
1740 0.5 - 0.5 * (std::f32::consts::TAU * t).cos()
1741}
1742
1743#[inline]
1744fn breathing_curve(t: f32, asymmetry: f32) -> f32 {
1745 let t = skew_phase(t, asymmetry);
1746 0.5 - 0.5 * (std::f32::consts::TAU * t).cos()
1747}
1748
1749#[inline]
1750fn skew_phase(t: f32, asymmetry: f32) -> f32 {
1751 let skew = asymmetry.clamp(-0.9, 0.9);
1752 if skew == 0.0 {
1753 return t;
1754 }
1755 if skew > 0.0 {
1756 t.powf(1.0 + skew * 2.0)
1757 } else {
1758 1.0 - (1.0 - t).powf(1.0 - skew * 2.0)
1759 }
1760}
1761
1762#[inline]
1763fn with_alpha(color: PackedRgba, alpha: f32) -> PackedRgba {
1764 let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
1765 PackedRgba::rgba(color.r(), color.g(), color.b(), a)
1766}
1767
1768#[inline]
1769fn blend_additive(top: PackedRgba, bottom: PackedRgba) -> PackedRgba {
1770 let ta = top.a() as f32 / 255.0;
1771 let r = (bottom.r() as f32 + top.r() as f32 * ta).min(255.0) as u8;
1772 let g = (bottom.g() as f32 + top.g() as f32 * ta).min(255.0) as u8;
1773 let b = (bottom.b() as f32 + top.b() as f32 * ta).min(255.0) as u8;
1774 let a = bottom.a().max(top.a());
1775 PackedRgba::rgba(r, g, b, a)
1776}
1777
1778#[inline]
1779fn blend_multiply(top: PackedRgba, bottom: PackedRgba) -> PackedRgba {
1780 let ta = top.a() as f32 / 255.0;
1781 let mr = (top.r() as f32 * bottom.r() as f32 / 255.0) as u8;
1782 let mg = (top.g() as f32 * bottom.g() as f32 / 255.0) as u8;
1783 let mb = (top.b() as f32 * bottom.b() as f32 / 255.0) as u8;
1784 let r = (bottom.r() as f32 * (1.0 - ta) + mr as f32 * ta) as u8;
1785 let g = (bottom.g() as f32 * (1.0 - ta) + mg as f32 * ta) as u8;
1786 let b = (bottom.b() as f32 * (1.0 - ta) + mb as f32 * ta) as u8;
1787 let a = bottom.a().max(top.a());
1788 PackedRgba::rgba(r, g, b, a)
1789}
1790
1791#[inline]
1792fn blend_screen(top: PackedRgba, bottom: PackedRgba) -> PackedRgba {
1793 let ta = top.a() as f32 / 255.0;
1794 let sr = 255 - ((255 - top.r()) as u16 * (255 - bottom.r()) as u16 / 255) as u8;
1795 let sg = 255 - ((255 - top.g()) as u16 * (255 - bottom.g()) as u16 / 255) as u8;
1796 let sb = 255 - ((255 - top.b()) as u16 * (255 - bottom.b()) as u16 / 255) as u8;
1797 let r = (bottom.r() as f32 * (1.0 - ta) + sr as f32 * ta) as u8;
1798 let g = (bottom.g() as f32 * (1.0 - ta) + sg as f32 * ta) as u8;
1799 let b = (bottom.b() as f32 * (1.0 - ta) + sb as f32 * ta) as u8;
1800 let a = bottom.a().max(top.a());
1801 PackedRgba::rgba(r, g, b, a)
1802}
1803
1804impl Default for TableTheme {
1805 fn default() -> Self {
1806 Self::graphite()
1807 }
1808}
1809
1810#[inline]
1811fn classic_color(profile: ColorProfile, rgb: (u8, u8, u8), ansi16: Ansi16) -> PackedRgba {
1812 let color = match profile {
1813 ColorProfile::Ansi16 => Color::Ansi16(ansi16),
1814 _ => Color::rgb(rgb.0, rgb.1, rgb.2).downgrade(profile),
1815 };
1816 let rgb = color.to_rgb();
1817 PackedRgba::rgb(rgb.r, rgb.g, rgb.b)
1818}
1819
1820#[derive(Clone, Copy, Debug)]
1825struct StableHasher {
1826 state: u64,
1827}
1828
1829impl StableHasher {
1830 const OFFSET: u64 = 0xcbf29ce484222325;
1831 const PRIME: u64 = 0x100000001b3;
1832
1833 #[must_use]
1834 const fn new() -> Self {
1835 Self {
1836 state: Self::OFFSET,
1837 }
1838 }
1839}
1840
1841impl Hasher for StableHasher {
1842 fn finish(&self) -> u64 {
1843 self.state
1844 }
1845
1846 fn write(&mut self, bytes: &[u8]) {
1847 let mut hash = self.state;
1848 for byte in bytes {
1849 hash ^= u64::from(*byte);
1850 hash = hash.wrapping_mul(Self::PRIME);
1851 }
1852 self.state = hash;
1853 }
1854}
1855
1856fn hash_u8(value: u8, hasher: &mut StableHasher) {
1857 hasher.write(&[value]);
1858}
1859
1860fn hash_u32(value: u32, hasher: &mut StableHasher) {
1861 hasher.write(&value.to_le_bytes());
1862}
1863
1864fn hash_u64(value: u64, hasher: &mut StableHasher) {
1865 hasher.write(&value.to_le_bytes());
1866}
1867
1868fn hash_usize(value: usize, hasher: &mut StableHasher) {
1869 hash_u64(value as u64, hasher);
1870}
1871
1872fn hash_f32(value: f32, hasher: &mut StableHasher) {
1873 hash_u32(value.to_bits(), hasher);
1874}
1875
1876fn hash_bool(value: bool, hasher: &mut StableHasher) {
1877 hash_u8(value as u8, hasher);
1878}
1879
1880fn hash_style(style: &Style, hasher: &mut StableHasher) {
1881 style.hash(hasher);
1882}
1883
1884fn hash_packed_rgba(color: PackedRgba, hasher: &mut StableHasher) {
1885 hash_u32(color.0, hasher);
1886}
1887
1888fn hash_preset(preset: Option<TablePresetId>, hasher: &mut StableHasher) {
1889 match preset {
1890 None => hash_u8(0, hasher),
1891 Some(id) => {
1892 hash_u8(1, hasher);
1893 hash_table_preset(id, hasher);
1894 }
1895 }
1896}
1897
1898fn hash_table_preset(preset: TablePresetId, hasher: &mut StableHasher) {
1899 let tag = match preset {
1900 TablePresetId::Aurora => 1,
1901 TablePresetId::Graphite => 2,
1902 TablePresetId::Neon => 3,
1903 TablePresetId::Slate => 4,
1904 TablePresetId::Solar => 5,
1905 TablePresetId::Orchard => 6,
1906 TablePresetId::Paper => 7,
1907 TablePresetId::Midnight => 8,
1908 TablePresetId::TerminalClassic => 9,
1909 };
1910 hash_u8(tag, hasher);
1911}
1912
1913fn hash_table_section(section: TableSection, hasher: &mut StableHasher) {
1914 let tag = match section {
1915 TableSection::Header => 1,
1916 TableSection::Body => 2,
1917 TableSection::Footer => 3,
1918 };
1919 hash_u8(tag, hasher);
1920}
1921
1922fn hash_blend_mode(mode: BlendMode, hasher: &mut StableHasher) {
1923 let tag = match mode {
1924 BlendMode::Replace => 1,
1925 BlendMode::Additive => 2,
1926 BlendMode::Multiply => 3,
1927 BlendMode::Screen => 4,
1928 };
1929 hash_u8(tag, hasher);
1930}
1931
1932fn hash_style_mask(mask: StyleMask, hasher: &mut StableHasher) {
1933 hash_bool(mask.fg, hasher);
1934 hash_bool(mask.bg, hasher);
1935 hash_bool(mask.attrs, hasher);
1936}
1937
1938fn hash_effect_target(target: &TableEffectTarget, hasher: &mut StableHasher) {
1939 match *target {
1940 TableEffectTarget::Section(section) => {
1941 hash_u8(1, hasher);
1942 hash_table_section(section, hasher);
1943 }
1944 TableEffectTarget::Row(row) => {
1945 hash_u8(2, hasher);
1946 hash_usize(row, hasher);
1947 }
1948 TableEffectTarget::RowRange { start, end } => {
1949 hash_u8(3, hasher);
1950 hash_usize(start, hasher);
1951 hash_usize(end, hasher);
1952 }
1953 TableEffectTarget::Column(column) => {
1954 hash_u8(4, hasher);
1955 hash_usize(column, hasher);
1956 }
1957 TableEffectTarget::ColumnRange { start, end } => {
1958 hash_u8(5, hasher);
1959 hash_usize(start, hasher);
1960 hash_usize(end, hasher);
1961 }
1962 TableEffectTarget::AllRows => {
1963 hash_u8(6, hasher);
1964 }
1965 TableEffectTarget::AllCells => {
1966 hash_u8(7, hasher);
1967 }
1968 }
1969}
1970
1971fn hash_gradient(gradient: &Gradient, hasher: &mut StableHasher) {
1972 hash_usize(gradient.stops.len(), hasher);
1973 for (pos, color) in &gradient.stops {
1974 hash_f32(*pos, hasher);
1975 hash_packed_rgba(*color, hasher);
1976 }
1977}
1978
1979fn hash_effect(effect: &TableEffect, hasher: &mut StableHasher) {
1980 match *effect {
1981 TableEffect::Pulse {
1982 fg_a,
1983 fg_b,
1984 bg_a,
1985 bg_b,
1986 speed,
1987 phase_offset,
1988 } => {
1989 hash_u8(1, hasher);
1990 hash_packed_rgba(fg_a, hasher);
1991 hash_packed_rgba(fg_b, hasher);
1992 hash_packed_rgba(bg_a, hasher);
1993 hash_packed_rgba(bg_b, hasher);
1994 hash_f32(speed, hasher);
1995 hash_f32(phase_offset, hasher);
1996 }
1997 TableEffect::BreathingGlow {
1998 fg,
1999 bg,
2000 intensity,
2001 speed,
2002 phase_offset,
2003 asymmetry,
2004 } => {
2005 hash_u8(2, hasher);
2006 hash_packed_rgba(fg, hasher);
2007 hash_packed_rgba(bg, hasher);
2008 hash_f32(intensity, hasher);
2009 hash_f32(speed, hasher);
2010 hash_f32(phase_offset, hasher);
2011 hash_f32(asymmetry, hasher);
2012 }
2013 TableEffect::GradientSweep {
2014 ref gradient,
2015 speed,
2016 phase_offset,
2017 } => {
2018 hash_u8(3, hasher);
2019 hash_gradient(gradient, hasher);
2020 hash_f32(speed, hasher);
2021 hash_f32(phase_offset, hasher);
2022 }
2023 }
2024}
2025
2026fn hash_effect_rule(rule: &TableEffectRule, hasher: &mut StableHasher) {
2027 hash_effect_target(&rule.target, hasher);
2028 hash_effect(&rule.effect, hasher);
2029 hash_u8(rule.priority, hasher);
2030 hash_blend_mode(rule.blend_mode, hasher);
2031 hash_style_mask(rule.style_mask, hasher);
2032}
2033
2034#[cfg(test)]
2035mod tests {
2036 use super::*;
2037 use crate::color::{WCAG_AA_LARGE_TEXT, WCAG_AA_NORMAL_TEXT, contrast_ratio_packed};
2038 #[cfg(feature = "serde")]
2039 use serde_json;
2040
2041 fn base_bg(theme: &TableTheme) -> PackedRgba {
2042 theme
2043 .row
2044 .bg
2045 .or(theme.row_alt.bg)
2046 .or(theme.header.bg)
2047 .or(theme.row_selected.bg)
2048 .or(theme.row_hover.bg)
2049 .unwrap_or(PackedRgba::BLACK)
2050 }
2051
2052 fn expect_fg(preset: TablePresetId, label: &str, style: Style) -> PackedRgba {
2053 let fg = style.fg;
2054 assert!(fg.is_some(), "{preset:?} missing fg for {label}");
2055 fg.unwrap()
2056 }
2057
2058 fn expect_bg(preset: TablePresetId, label: &str, style: Style) -> PackedRgba {
2059 let bg = style.bg;
2060 assert!(bg.is_some(), "{preset:?} missing bg for {label}");
2061 bg.unwrap()
2062 }
2063
2064 fn assert_contrast(
2065 preset: TablePresetId,
2066 label: &str,
2067 fg: PackedRgba,
2068 bg: PackedRgba,
2069 minimum: f64,
2070 ) {
2071 let ratio = contrast_ratio_packed(fg, bg);
2072 assert!(
2073 ratio >= minimum,
2074 "{preset:?} {label} contrast {ratio:.2} below {minimum:.2}"
2075 );
2076 }
2077
2078 fn pulse_effect(fg: PackedRgba, bg: PackedRgba) -> TableEffect {
2079 TableEffect::Pulse {
2080 fg_a: fg,
2081 fg_b: fg,
2082 bg_a: bg,
2083 bg_b: bg,
2084 speed: 1.0,
2085 phase_offset: 0.0,
2086 }
2087 }
2088
2089 fn assert_f32_near(label: &str, value: f32, expected: f32) {
2090 let delta = (value - expected).abs();
2091 assert!(delta <= 1e-6, "{label} expected {expected}, got {value}");
2092 }
2093
2094 #[test]
2095 fn style_mask_default_is_fg_bg() {
2096 let mask = StyleMask::default();
2097 assert!(mask.fg);
2098 assert!(mask.bg);
2099 assert!(!mask.attrs);
2100 }
2101
2102 #[test]
2103 fn effect_target_matches_scope_variants() {
2104 let row_scope = TableEffectScope::row(TableSection::Body, 2);
2105 assert!(TableEffectTarget::Section(TableSection::Body).matches_scope(row_scope));
2106 assert!(!TableEffectTarget::Section(TableSection::Header).matches_scope(row_scope));
2107 assert!(TableEffectTarget::Row(2).matches_scope(row_scope));
2108 assert!(!TableEffectTarget::Row(1).matches_scope(row_scope));
2109 assert!(TableEffectTarget::RowRange { start: 1, end: 3 }.matches_scope(row_scope));
2110 assert!(!TableEffectTarget::RowRange { start: 3, end: 5 }.matches_scope(row_scope));
2111 assert!(TableEffectTarget::AllRows.matches_scope(row_scope));
2112 assert!(TableEffectTarget::AllCells.matches_scope(row_scope));
2113 assert!(!TableEffectTarget::Column(0).matches_scope(row_scope));
2114
2115 let col_scope = TableEffectScope::column(TableSection::Header, 1);
2116 assert!(TableEffectTarget::Column(1).matches_scope(col_scope));
2117 assert!(TableEffectTarget::ColumnRange { start: 0, end: 2 }.matches_scope(col_scope));
2118 assert!(!TableEffectTarget::AllRows.matches_scope(col_scope));
2119 assert!(TableEffectTarget::AllCells.matches_scope(col_scope));
2120
2121 let footer_scope = TableEffectScope::row(TableSection::Footer, 0);
2122 assert!(!TableEffectTarget::AllCells.matches_scope(footer_scope));
2123
2124 let header_section = TableEffectScope::section(TableSection::Header);
2125 assert!(!TableEffectTarget::AllCells.matches_scope(header_section));
2126 }
2127
2128 #[test]
2129 fn effect_resolver_returns_base_without_effects() {
2130 let base = Style::new()
2131 .fg(PackedRgba::rgb(12, 34, 56))
2132 .bg(PackedRgba::rgb(7, 8, 9));
2133 let mut theme = TableTheme::aurora();
2134 theme.effects.clear();
2135
2136 let resolver = theme.effect_resolver();
2137 let scope = TableEffectScope::row(TableSection::Body, 0);
2138 let resolved = resolver.resolve(base, scope, 0.25);
2139 assert_eq!(resolved, base);
2140 }
2141
2142 #[test]
2143 fn effect_resolver_all_rows_excludes_header() {
2144 let base = Style::new().fg(PackedRgba::rgb(10, 10, 10));
2145 let mut theme = TableTheme::aurora();
2146 theme.effects = vec![TableEffectRule::new(
2148 TableEffectTarget::AllRows,
2149 pulse_effect(PackedRgba::rgb(200, 0, 0), PackedRgba::rgb(5, 5, 5)),
2150 )];
2151
2152 let resolver = theme.effect_resolver();
2153 let header_scope = TableEffectScope::row(TableSection::Header, 0);
2154 let body_scope = TableEffectScope::row(TableSection::Body, 0);
2155
2156 let header = resolver.resolve(base, header_scope, 0.5);
2157 let body = resolver.resolve(base, body_scope, 0.5);
2158 assert_eq!(header, base);
2159 assert_eq!(body.fg, Some(PackedRgba::rgb(200, 0, 0)));
2160 }
2161
2162 #[test]
2163 fn effect_resolver_all_cells_includes_header_rows() {
2164 let base = Style::new().fg(PackedRgba::rgb(10, 10, 10));
2165 let mut theme = TableTheme::aurora();
2166 theme.effects = vec![TableEffectRule::new(
2168 TableEffectTarget::AllCells,
2169 pulse_effect(PackedRgba::rgb(0, 200, 0), PackedRgba::rgb(5, 5, 5)),
2170 )];
2171
2172 let resolver = theme.effect_resolver();
2173 let header_scope = TableEffectScope::row(TableSection::Header, 0);
2174 let resolved = resolver.resolve(base, header_scope, 0.5);
2175 assert_eq!(resolved.fg, Some(PackedRgba::rgb(0, 200, 0)));
2176 }
2177
2178 #[test]
2179 fn normalize_phase_wraps_and_curves_are_deterministic() {
2180 assert_f32_near("normalize_phase(-0.25)", normalize_phase(-0.25), 0.75);
2181 assert_f32_near("normalize_phase(1.25)", normalize_phase(1.25), 0.25);
2182 assert_f32_near("pulse_curve(0.0)", pulse_curve(0.0), 0.0);
2183 assert_f32_near("pulse_curve(0.5)", pulse_curve(0.5), 1.0);
2184 assert_f32_near(
2185 "breathing_curve matches pulse at zero asymmetry",
2186 breathing_curve(0.25, 0.0),
2187 pulse_curve(0.25),
2188 );
2189 }
2190
2191 #[test]
2192 fn lerp_color_clamps_out_of_range_t() {
2193 let a = PackedRgba::rgb(0, 0, 0);
2194 let b = PackedRgba::rgb(255, 255, 255);
2195 assert_eq!(lerp_color(a, b, -1.0), a);
2196 assert_eq!(lerp_color(a, b, 2.0), b);
2197 }
2198
2199 #[test]
2200 fn effect_resolver_respects_priority_order() {
2201 let base = Style::new()
2202 .fg(PackedRgba::rgb(10, 10, 10))
2203 .bg(PackedRgba::rgb(20, 20, 20));
2204 let mut theme = TableTheme::aurora();
2205 theme.effects = vec![
2206 TableEffectRule::new(
2207 TableEffectTarget::AllRows,
2208 pulse_effect(PackedRgba::rgb(200, 0, 0), PackedRgba::rgb(0, 0, 0)),
2209 )
2210 .priority(0),
2211 TableEffectRule::new(
2212 TableEffectTarget::AllRows,
2213 pulse_effect(PackedRgba::rgb(0, 0, 200), PackedRgba::rgb(0, 0, 80)),
2214 )
2215 .priority(5),
2216 ];
2217
2218 let resolver = theme.effect_resolver();
2219 let scope = TableEffectScope::row(TableSection::Body, 0);
2220 let resolved = resolver.resolve(base, scope, 0.0);
2221 assert_eq!(resolved.fg, Some(PackedRgba::rgb(0, 0, 200)));
2222 assert_eq!(resolved.bg, Some(PackedRgba::rgb(0, 0, 80)));
2223 }
2224
2225 #[test]
2226 fn effect_resolver_applies_same_priority_in_list_order() {
2227 let base = Style::new().fg(PackedRgba::rgb(5, 5, 5));
2228 let mut theme = TableTheme::aurora();
2229 theme.effects = vec![
2230 TableEffectRule::new(
2231 TableEffectTarget::Row(0),
2232 pulse_effect(PackedRgba::rgb(10, 10, 10), PackedRgba::BLACK),
2233 )
2234 .priority(1),
2235 TableEffectRule::new(
2236 TableEffectTarget::Row(0),
2237 pulse_effect(PackedRgba::rgb(40, 40, 40), PackedRgba::BLACK),
2238 )
2239 .priority(1),
2240 ];
2241
2242 let resolver = theme.effect_resolver();
2243 let scope = TableEffectScope::row(TableSection::Body, 0);
2244 let resolved = resolver.resolve(base, scope, 0.0);
2245 assert_eq!(resolved.fg, Some(PackedRgba::rgb(40, 40, 40)));
2246 }
2247
2248 #[test]
2249 fn effect_resolver_respects_style_mask() {
2250 let base = Style::new()
2251 .fg(PackedRgba::rgb(10, 20, 30))
2252 .bg(PackedRgba::rgb(1, 2, 3));
2253 let mut theme = TableTheme::aurora();
2254 theme.effects = vec![
2255 TableEffectRule::new(
2256 TableEffectTarget::Row(0),
2257 pulse_effect(PackedRgba::rgb(200, 100, 0), PackedRgba::rgb(9, 9, 9)),
2258 )
2259 .style_mask(StyleMask::none()),
2260 ];
2261
2262 let resolver = theme.effect_resolver();
2263 let scope = TableEffectScope::row(TableSection::Body, 0);
2264 let resolved = resolver.resolve(base, scope, 0.0);
2265 assert_eq!(resolved, base);
2266
2267 theme.effects = vec![
2268 TableEffectRule::new(
2269 TableEffectTarget::Row(0),
2270 pulse_effect(PackedRgba::rgb(200, 100, 0), PackedRgba::rgb(9, 9, 9)),
2271 )
2272 .style_mask(StyleMask {
2273 fg: true,
2274 bg: false,
2275 attrs: false,
2276 }),
2277 ];
2278 let resolver = theme.effect_resolver();
2279 let resolved = resolver.resolve(base, scope, 0.0);
2280 assert_eq!(resolved.fg, Some(PackedRgba::rgb(200, 100, 0)));
2281 assert_eq!(resolved.bg, base.bg);
2282 }
2283
2284 #[test]
2285 fn effect_resolver_skips_alpha_zero() {
2286 let base = Style::new()
2287 .fg(PackedRgba::rgb(10, 10, 10))
2288 .bg(PackedRgba::rgb(20, 20, 20));
2289 let mut theme = TableTheme::aurora();
2290 theme.effects = vec![TableEffectRule::new(
2291 TableEffectTarget::Row(0),
2292 TableEffect::BreathingGlow {
2293 fg: PackedRgba::rgb(200, 200, 200),
2294 bg: PackedRgba::rgb(10, 10, 10),
2295 intensity: 0.0,
2296 speed: 1.0,
2297 phase_offset: 0.0,
2298 asymmetry: 0.0,
2299 },
2300 )];
2301
2302 let resolver = theme.effect_resolver();
2303 let scope = TableEffectScope::row(TableSection::Body, 0);
2304 let resolved = resolver.resolve(base, scope, 0.5);
2305 assert_eq!(resolved, base);
2306 }
2307
2308 #[test]
2309 fn presets_set_preset_id() {
2310 let theme = TableTheme::aurora();
2311 assert_eq!(theme.preset_id, Some(TablePresetId::Aurora));
2312 }
2313
2314 #[test]
2315 fn terminal_classic_keeps_profile() {
2316 let theme = TableTheme::terminal_classic_for(ColorProfile::Ansi16);
2317 assert_eq!(theme.preset_id, Some(TablePresetId::TerminalClassic));
2318 assert!(theme.column_gap > 0);
2319 }
2320
2321 #[test]
2322 fn style_hash_is_deterministic() {
2323 let theme = TableTheme::aurora();
2324 let h1 = theme.style_hash();
2325 let h2 = theme.style_hash();
2326 assert_eq!(h1, h2, "style_hash should be stable for identical input");
2327 }
2328
2329 #[test]
2330 fn style_hash_changes_with_layout_params() {
2331 let mut theme = TableTheme::aurora();
2332 let base = theme.style_hash();
2333 theme.padding = theme.padding.saturating_add(1);
2334 assert_ne!(
2335 base,
2336 theme.style_hash(),
2337 "padding should influence style hash"
2338 );
2339 }
2340
2341 #[test]
2342 fn effects_hash_changes_with_rules() {
2343 let mut theme = TableTheme::aurora();
2344 let base = theme.effects_hash();
2345 theme.effects.push(TableEffectRule::new(
2346 TableEffectTarget::AllRows,
2347 TableEffect::BreathingGlow {
2348 fg: PackedRgba::rgb(200, 220, 255),
2349 bg: PackedRgba::rgb(30, 40, 60),
2350 intensity: 0.6,
2351 speed: 0.8,
2352 phase_offset: 0.1,
2353 asymmetry: 0.2,
2354 },
2355 ));
2356 assert_ne!(
2357 base,
2358 theme.effects_hash(),
2359 "effects hash should change with rules"
2360 );
2361 }
2362
2363 #[test]
2364 fn presets_meet_wcag_contrast_targets() {
2365 let presets = [
2366 TablePresetId::Aurora,
2367 TablePresetId::Graphite,
2368 TablePresetId::Neon,
2369 TablePresetId::Slate,
2370 TablePresetId::Solar,
2371 TablePresetId::Orchard,
2372 TablePresetId::Paper,
2373 TablePresetId::Midnight,
2374 TablePresetId::TerminalClassic,
2375 ];
2376
2377 for preset in presets {
2378 let theme = match preset {
2379 TablePresetId::TerminalClassic => {
2380 TableTheme::terminal_classic_for(ColorProfile::Ansi16)
2381 }
2382 _ => TableTheme::preset(preset),
2383 };
2384 let base = base_bg(&theme);
2385
2386 let header_fg = expect_fg(preset, "header", theme.header);
2387 let header_bg = expect_bg(preset, "header", theme.header);
2388 assert_contrast(preset, "header", header_fg, header_bg, WCAG_AA_NORMAL_TEXT);
2389
2390 let row_fg = expect_fg(preset, "row", theme.row);
2391 let row_bg = theme.row.bg.unwrap_or(base);
2392 assert_contrast(preset, "row", row_fg, row_bg, WCAG_AA_NORMAL_TEXT);
2393
2394 let row_alt_fg = expect_fg(preset, "row_alt", theme.row_alt);
2395 let row_alt_bg = expect_bg(preset, "row_alt", theme.row_alt);
2396 assert_contrast(
2397 preset,
2398 "row_alt",
2399 row_alt_fg,
2400 row_alt_bg,
2401 WCAG_AA_NORMAL_TEXT,
2402 );
2403
2404 let selected_fg = expect_fg(preset, "row_selected", theme.row_selected);
2405 let selected_bg = expect_bg(preset, "row_selected", theme.row_selected);
2406 assert_contrast(
2407 preset,
2408 "row_selected",
2409 selected_fg,
2410 selected_bg,
2411 WCAG_AA_NORMAL_TEXT,
2412 );
2413
2414 let hover_fg = expect_fg(preset, "row_hover", theme.row_hover);
2415 let hover_bg = expect_bg(preset, "row_hover", theme.row_hover);
2416 let hover_min = if preset == TablePresetId::TerminalClassic {
2417 WCAG_AA_LARGE_TEXT
2419 } else {
2420 WCAG_AA_NORMAL_TEXT
2421 };
2422 assert_contrast(preset, "row_hover", hover_fg, hover_bg, hover_min);
2423
2424 let border_fg = expect_fg(preset, "border", theme.border);
2425 assert_contrast(preset, "border", border_fg, base, WCAG_AA_LARGE_TEXT);
2426
2427 let divider_fg = expect_fg(preset, "divider", theme.divider);
2428 assert_contrast(preset, "divider", divider_fg, base, WCAG_AA_LARGE_TEXT);
2429 }
2430 }
2431
2432 fn base_spec() -> TableThemeSpec {
2433 TableThemeSpec::from_theme(&TableTheme::aurora())
2434 }
2435
2436 fn sample_rule() -> TableEffectRuleSpec {
2437 TableEffectRuleSpec {
2438 target: TableEffectTarget::AllRows,
2439 effect: TableEffectSpec::Pulse {
2440 fg_a: RgbaSpec::new(10, 20, 30, 255),
2441 fg_b: RgbaSpec::new(40, 50, 60, 255),
2442 bg_a: RgbaSpec::new(5, 5, 5, 255),
2443 bg_b: RgbaSpec::new(9, 9, 9, 255),
2444 speed: 1.0,
2445 phase_offset: 0.0,
2446 },
2447 priority: 0,
2448 blend_mode: BlendMode::Replace,
2449 style_mask: StyleMask::fg_bg(),
2450 }
2451 }
2452
2453 #[test]
2454 fn table_theme_spec_validate_accepts_defaults() {
2455 let spec = base_spec();
2456 assert!(spec.validate().is_ok());
2457 }
2458
2459 #[test]
2460 fn table_theme_spec_validate_rejects_padding_overflow() {
2461 let mut spec = base_spec();
2462 spec.padding = TABLE_THEME_SPEC_MAX_PADDING.saturating_add(1);
2463 let err = spec.validate().expect_err("expected padding range error");
2464 assert_eq!(err.field, "padding");
2465 }
2466
2467 #[test]
2468 fn table_theme_spec_validate_rejects_name_length_overflow() {
2469 let mut spec = base_spec();
2470 spec.name = Some("x".repeat(TABLE_THEME_SPEC_MAX_NAME_LEN.saturating_add(1)));
2471 let err = spec.validate().expect_err("expected name length error");
2472 assert_eq!(err.field, "name");
2473 }
2474
2475 #[test]
2476 fn table_theme_spec_validate_rejects_effect_count_overflow() {
2477 let mut spec = base_spec();
2478 spec.effects = vec![sample_rule(); TABLE_THEME_SPEC_MAX_EFFECTS.saturating_add(1)];
2479 let err = spec.validate().expect_err("expected effects length error");
2480 assert_eq!(err.field, "effects");
2481 }
2482
2483 #[test]
2484 fn table_theme_spec_validate_rejects_style_attr_overflow() {
2485 let mut spec = base_spec();
2486 spec.styles.header.attrs =
2487 vec![StyleAttr::Bold; TABLE_THEME_SPEC_MAX_STYLE_ATTRS.saturating_add(1)];
2488 let err = spec
2489 .validate()
2490 .expect_err("expected style attr length error");
2491 assert_eq!(err.field, "styles.header.attrs");
2492 }
2493
2494 #[test]
2495 fn table_theme_spec_validate_rejects_gradient_stop_count_out_of_range() {
2496 let mut spec = base_spec();
2497 spec.effects = vec![TableEffectRuleSpec {
2498 target: TableEffectTarget::AllRows,
2499 effect: TableEffectSpec::GradientSweep {
2500 gradient: GradientSpec { stops: Vec::new() },
2501 speed: 1.0,
2502 phase_offset: 0.0,
2503 },
2504 priority: 0,
2505 blend_mode: BlendMode::Replace,
2506 style_mask: StyleMask::fg_bg(),
2507 }];
2508 let err = spec
2509 .validate()
2510 .expect_err("expected gradient stop count error");
2511 assert!(
2512 err.field.contains("gradient.stops"),
2513 "unexpected field: {}",
2514 err.field
2515 );
2516 }
2517
2518 #[test]
2519 fn table_theme_spec_validate_rejects_gradient_stop_out_of_range() {
2520 let mut spec = base_spec();
2521 spec.effects = vec![TableEffectRuleSpec {
2522 target: TableEffectTarget::AllRows,
2523 effect: TableEffectSpec::GradientSweep {
2524 gradient: GradientSpec {
2525 stops: vec![GradientStopSpec {
2526 pos: 1.5,
2527 color: RgbaSpec::new(0, 0, 0, 255),
2528 }],
2529 },
2530 speed: 1.0,
2531 phase_offset: 0.0,
2532 },
2533 priority: 0,
2534 blend_mode: BlendMode::Replace,
2535 style_mask: StyleMask::fg_bg(),
2536 }];
2537 let err = spec
2538 .validate()
2539 .expect_err("expected gradient stop range error");
2540 assert!(
2541 err.field.contains("gradient.stops"),
2542 "unexpected field: {}",
2543 err.field
2544 );
2545 }
2546
2547 #[test]
2548 fn table_theme_spec_validate_rejects_inverted_row_range() {
2549 let mut spec = base_spec();
2550 let mut rule = sample_rule();
2551 rule.target = TableEffectTarget::RowRange { start: 3, end: 1 };
2552 spec.effects = vec![rule];
2553 let err = spec.validate().expect_err("expected target range error");
2554 assert!(
2555 err.field.contains("target"),
2556 "unexpected field: {}",
2557 err.field
2558 );
2559 }
2560
2561 #[cfg(feature = "serde")]
2562 #[test]
2563 fn table_theme_spec_json_rejects_unknown_field() {
2564 let mut value = serde_json::to_value(base_spec()).expect("TableThemeSpec should serialize");
2565 let obj = value.as_object_mut().expect("spec should be an object");
2566 obj.insert("unknown_field".to_string(), serde_json::json!(true));
2567 let err = serde_json::from_value::<TableThemeSpec>(value)
2568 .expect_err("expected unknown field error");
2569 assert!(
2570 err.to_string().contains("unknown field"),
2571 "unexpected error: {err}"
2572 );
2573 }
2574
2575 #[cfg(feature = "serde")]
2576 #[test]
2577 fn table_theme_spec_json_has_canonical_key_order() {
2578 let json =
2579 serde_json::to_string_pretty(&base_spec()).expect("TableThemeSpec should serialize");
2580 let keys = [
2581 "\"version\"",
2582 "\"name\"",
2583 "\"preset_id\"",
2584 "\"padding\"",
2585 "\"column_gap\"",
2586 "\"row_height\"",
2587 "\"styles\"",
2588 "\"effects\"",
2589 ];
2590 let mut last = 0usize;
2591 for key in keys {
2592 let pos = json.find(key);
2593 assert!(pos.is_some(), "missing key {key}");
2594 let pos = pos.unwrap();
2595 assert!(
2596 pos >= last,
2597 "key {key} is out of order (pos {pos} < {last})"
2598 );
2599 last = pos;
2600 }
2601 }
2602
2603 #[test]
2608 fn gradient_empty_returns_transparent() {
2609 let g = Gradient::new(vec![]);
2610 let c = g.sample(0.5);
2611 assert_eq!(c, PackedRgba::TRANSPARENT);
2612 }
2613
2614 #[test]
2615 fn gradient_single_stop_returns_that_color() {
2616 let red = PackedRgba::rgb(255, 0, 0);
2617 let g = Gradient::new(vec![(0.5, red)]);
2618 assert_eq!(g.sample(0.0), red);
2619 assert_eq!(g.sample(0.5), red);
2620 assert_eq!(g.sample(1.0), red);
2621 }
2622
2623 #[test]
2624 fn gradient_two_stops_interpolates() {
2625 let black = PackedRgba::rgb(0, 0, 0);
2626 let white = PackedRgba::rgb(255, 255, 255);
2627 let g = Gradient::new(vec![(0.0, black), (1.0, white)]);
2628 let mid = g.sample(0.5);
2629 assert!(mid.r() > 120 && mid.r() < 135, "mid.r() = {}", mid.r());
2631 }
2632
2633 #[test]
2634 fn gradient_sorts_stops() {
2635 let a = PackedRgba::rgb(255, 0, 0);
2636 let b = PackedRgba::rgb(0, 255, 0);
2637 let g = Gradient::new(vec![(0.8, b), (0.2, a)]);
2638 let stops = g.stops();
2639 assert!(stops[0].0 < stops[1].0, "stops should be sorted");
2640 }
2641
2642 #[test]
2643 fn gradient_clamps_t() {
2644 let red = PackedRgba::rgb(255, 0, 0);
2645 let blue = PackedRgba::rgb(0, 0, 255);
2646 let g = Gradient::new(vec![(0.0, red), (1.0, blue)]);
2647 assert_eq!(g.sample(-1.0), red);
2648 assert_eq!(g.sample(2.0), blue);
2649 }
2650
2651 #[test]
2656 fn lerp_u8_basic() {
2657 assert_eq!(lerp_u8(0, 100, 0.0), 0);
2658 assert_eq!(lerp_u8(0, 100, 1.0), 100);
2659 assert_eq!(lerp_u8(0, 100, 0.5), 50);
2660 }
2661
2662 #[test]
2663 fn lerp_u8_clamps() {
2664 assert_eq!(lerp_u8(100, 200, -1.0), 0);
2666 assert_eq!(lerp_u8(0, 100, 2.0), 200);
2667 }
2668
2669 #[test]
2674 fn style_mask_all() {
2675 let mask = StyleMask::all();
2676 assert!(mask.fg);
2677 assert!(mask.bg);
2678 assert!(mask.attrs);
2679 }
2680
2681 #[test]
2682 fn style_mask_none() {
2683 let mask = StyleMask::none();
2684 assert!(!mask.fg);
2685 assert!(!mask.bg);
2686 assert!(!mask.attrs);
2687 }
2688
2689 #[test]
2690 fn style_mask_fg_bg_no_attrs() {
2691 let mask = StyleMask::fg_bg();
2692 assert!(mask.fg);
2693 assert!(mask.bg);
2694 assert!(!mask.attrs);
2695 }
2696
2697 #[test]
2702 fn effect_rule_defaults() {
2703 let rule = TableEffectRule::new(
2704 TableEffectTarget::AllRows,
2705 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2706 );
2707 assert_eq!(rule.priority, 0);
2708 assert_eq!(rule.blend_mode, BlendMode::Replace);
2709 assert_eq!(rule.style_mask, StyleMask::fg_bg());
2710 }
2711
2712 #[test]
2713 fn effect_rule_builder_chain() {
2714 let rule = TableEffectRule::new(
2715 TableEffectTarget::AllRows,
2716 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2717 )
2718 .priority(5)
2719 .blend_mode(BlendMode::Additive)
2720 .style_mask(StyleMask::all());
2721
2722 assert_eq!(rule.priority, 5);
2723 assert_eq!(rule.blend_mode, BlendMode::Additive);
2724 assert_eq!(rule.style_mask, StyleMask::all());
2725 }
2726
2727 #[test]
2732 fn default_theme_is_graphite() {
2733 let theme = TableTheme::default();
2734 assert_eq!(theme.preset_id, Some(TablePresetId::Graphite));
2735 }
2736
2737 #[test]
2738 fn preset_factory_matches_named() {
2739 let from_preset = TableTheme::preset(TablePresetId::Aurora);
2740 let from_named = TableTheme::aurora();
2741 assert_eq!(from_preset.style_hash(), from_named.style_hash());
2742 }
2743
2744 #[test]
2745 fn with_padding_sets_value() {
2746 let theme = TableTheme::graphite().with_padding(3);
2747 assert_eq!(theme.padding, 3);
2748 }
2749
2750 #[test]
2751 fn with_column_gap_sets_value() {
2752 let theme = TableTheme::graphite().with_column_gap(5);
2753 assert_eq!(theme.column_gap, 5);
2754 }
2755
2756 #[test]
2757 fn with_row_height_sets_value() {
2758 let theme = TableTheme::graphite().with_row_height(2);
2759 assert_eq!(theme.row_height, 2);
2760 }
2761
2762 #[test]
2763 fn with_effect_appends() {
2764 let theme = TableTheme::graphite().with_effect(TableEffectRule::new(
2765 TableEffectTarget::AllRows,
2766 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2767 ));
2768 assert_eq!(theme.effects.len(), 1);
2769 }
2770
2771 #[test]
2772 fn clear_effects_removes_all() {
2773 let theme = TableTheme::graphite()
2774 .with_effect(TableEffectRule::new(
2775 TableEffectTarget::AllRows,
2776 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2777 ))
2778 .clear_effects();
2779 assert!(theme.effects.is_empty());
2780 }
2781
2782 #[test]
2783 fn with_preset_id_overrides() {
2784 let theme = TableTheme::graphite().with_preset_id(Some(TablePresetId::Neon));
2785 assert_eq!(theme.preset_id, Some(TablePresetId::Neon));
2786 }
2787
2788 #[test]
2793 fn diagnostics_captures_theme_state() {
2794 let theme = TableTheme::aurora()
2795 .with_padding(4)
2796 .with_effect(TableEffectRule::new(
2797 TableEffectTarget::AllRows,
2798 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2799 ));
2800 let diag = theme.diagnostics();
2801 assert_eq!(diag.preset_id, Some(TablePresetId::Aurora));
2802 assert_eq!(diag.padding, 4);
2803 assert_eq!(diag.effect_count, 1);
2804 assert_ne!(diag.style_hash, 0);
2805 }
2806
2807 #[test]
2812 fn scope_section_has_no_row_or_column() {
2813 let s = TableEffectScope::section(TableSection::Header);
2814 assert_eq!(s.section, TableSection::Header);
2815 assert_eq!(s.row, None);
2816 assert_eq!(s.column, None);
2817 }
2818
2819 #[test]
2820 fn scope_row_has_row_no_column() {
2821 let s = TableEffectScope::row(TableSection::Body, 3);
2822 assert_eq!(s.row, Some(3));
2823 assert_eq!(s.column, None);
2824 }
2825
2826 #[test]
2827 fn scope_column_has_column_no_row() {
2828 let s = TableEffectScope::column(TableSection::Footer, 7);
2829 assert_eq!(s.column, Some(7));
2830 assert_eq!(s.row, None);
2831 }
2832
2833 #[test]
2838 fn pulse_curve_boundaries() {
2839 assert_f32_near("pulse(0.0)", pulse_curve(0.0), 0.0);
2840 assert_f32_near("pulse(0.5)", pulse_curve(0.5), 1.0);
2841 assert_f32_near("pulse(1.0)", pulse_curve(1.0), 0.0);
2842 }
2843
2844 #[test]
2845 fn breathing_curve_zero_asymmetry_matches_pulse() {
2846 for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
2847 assert_f32_near(
2848 &format!("breathing({t})"),
2849 breathing_curve(t, 0.0),
2850 pulse_curve(t),
2851 );
2852 }
2853 }
2854
2855 #[test]
2856 fn skew_phase_zero_is_identity() {
2857 for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
2858 assert_f32_near(&format!("skew({t})"), skew_phase(t, 0.0), t);
2859 }
2860 }
2861
2862 #[test]
2863 fn skew_phase_positive_slows_start() {
2864 let skewed = skew_phase(0.5, 0.5);
2866 assert!(
2867 skewed < 0.5,
2868 "positive skew should compress early phase: {skewed}"
2869 );
2870 }
2871
2872 #[test]
2873 fn skew_phase_negative_accelerates_start() {
2874 let skewed = skew_phase(0.5, -0.5);
2875 assert!(
2876 skewed > 0.5,
2877 "negative skew should expand early phase: {skewed}"
2878 );
2879 }
2880
2881 #[test]
2886 fn effect_resolver_additive_blend() {
2887 let base_fg = PackedRgba::rgb(100, 50, 50);
2888 let effect_fg = PackedRgba::rgb(50, 50, 50);
2889 let theme = TableTheme::graphite().with_effect(
2890 TableEffectRule::new(
2891 TableEffectTarget::AllRows,
2892 TableEffect::Pulse {
2893 fg_a: effect_fg,
2894 fg_b: effect_fg,
2895 bg_a: PackedRgba::BLACK,
2896 bg_b: PackedRgba::BLACK,
2897 speed: 1.0,
2898 phase_offset: 0.0,
2899 },
2900 )
2901 .blend_mode(BlendMode::Additive),
2902 );
2903 let resolver = theme.effect_resolver();
2904 let base = Style::new().fg(base_fg).bg(PackedRgba::BLACK);
2905 let resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.25);
2906 let resolved_fg = resolved.fg.unwrap();
2908 assert!(
2909 resolved_fg.r() >= base_fg.r(),
2910 "additive should brighten red"
2911 );
2912 }
2913
2914 #[test]
2915 fn effect_resolver_multiply_blend() {
2916 let base_fg = PackedRgba::rgb(200, 200, 200);
2917 let effect_fg = PackedRgba::rgb(128, 128, 128);
2918 let theme = TableTheme::graphite().with_effect(
2919 TableEffectRule::new(
2920 TableEffectTarget::AllRows,
2921 TableEffect::Pulse {
2922 fg_a: effect_fg,
2923 fg_b: effect_fg,
2924 bg_a: PackedRgba::BLACK,
2925 bg_b: PackedRgba::BLACK,
2926 speed: 1.0,
2927 phase_offset: 0.0,
2928 },
2929 )
2930 .blend_mode(BlendMode::Multiply),
2931 );
2932 let resolver = theme.effect_resolver();
2933 let base = Style::new().fg(base_fg).bg(PackedRgba::BLACK);
2934 let resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.25);
2935 let resolved_fg = resolved.fg.unwrap();
2936 assert!(resolved_fg.r() <= base_fg.r(), "multiply should darken");
2938 }
2939
2940 #[test]
2945 fn gradient_coincident_stops_returns_first() {
2946 let a = PackedRgba::rgb(255, 0, 0);
2947 let b = PackedRgba::rgb(0, 0, 255);
2948 let g = Gradient::new(vec![(0.5, a), (0.5, b)]);
2949 let c = g.sample(0.5);
2951 assert_eq!(c, a);
2952 let c_before = g.sample(0.3);
2954 assert_eq!(c_before, a);
2955 }
2956
2957 #[test]
2958 fn gradient_three_stops_middle_interpolation() {
2959 let r = PackedRgba::rgb(255, 0, 0);
2960 let g_color = PackedRgba::rgb(0, 255, 0);
2961 let b = PackedRgba::rgb(0, 0, 255);
2962 let g = Gradient::new(vec![(0.0, r), (0.5, g_color), (1.0, b)]);
2963 let c = g.sample(0.25);
2965 assert!(c.r() > 100, "should have red component: {}", c.r());
2966 assert!(c.g() > 100, "should have green component: {}", c.g());
2967 assert!(c.b() < 10, "should have minimal blue: {}", c.b());
2968 }
2969
2970 #[test]
2971 fn gradient_partial_eq() {
2972 let a = Gradient::new(vec![(0.0, PackedRgba::RED), (1.0, PackedRgba::BLACK)]);
2973 let b = Gradient::new(vec![(0.0, PackedRgba::RED), (1.0, PackedRgba::BLACK)]);
2974 assert_eq!(a, b);
2975 }
2976
2977 #[test]
2978 fn lerp_u8_same_values() {
2979 assert_eq!(lerp_u8(128, 128, 0.5), 128);
2980 }
2981
2982 #[test]
2983 fn lerp_u8_max_to_max() {
2984 assert_eq!(lerp_u8(255, 255, 0.5), 255);
2985 }
2986
2987 #[test]
2988 fn lerp_color_exact_midpoint() {
2989 let a = PackedRgba::rgba(0, 0, 0, 0);
2990 let b = PackedRgba::rgba(200, 100, 50, 200);
2991 let mid = lerp_color(a, b, 0.5);
2992 assert_eq!(mid.r(), 100);
2993 assert_eq!(mid.g(), 50);
2994 assert_eq!(mid.b(), 25);
2995 assert_eq!(mid.a(), 100);
2996 }
2997
2998 #[test]
2999 fn blend_mode_default_is_replace() {
3000 assert_eq!(BlendMode::default(), BlendMode::Replace);
3001 }
3002
3003 #[test]
3004 fn blend_mode_traits() {
3005 let mode = BlendMode::Screen;
3006 let debug = format!("{:?}", mode);
3007 assert!(debug.contains("Screen"));
3008 let cloned = mode;
3009 assert_eq!(mode, cloned);
3010 assert_ne!(BlendMode::Additive, BlendMode::Multiply);
3011 }
3012
3013 #[test]
3014 fn effect_resolver_screen_blend() {
3015 let base_fg = PackedRgba::rgb(100, 100, 100);
3016 let effect_fg = PackedRgba::rgb(128, 128, 128);
3017 let theme = TableTheme::graphite().with_effect(
3018 TableEffectRule::new(
3019 TableEffectTarget::AllRows,
3020 TableEffect::Pulse {
3021 fg_a: effect_fg,
3022 fg_b: effect_fg,
3023 bg_a: PackedRgba::BLACK,
3024 bg_b: PackedRgba::BLACK,
3025 speed: 1.0,
3026 phase_offset: 0.0,
3027 },
3028 )
3029 .blend_mode(BlendMode::Screen),
3030 );
3031 let resolver = theme.effect_resolver();
3032 let base = Style::new().fg(base_fg).bg(PackedRgba::BLACK);
3033 let resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.25);
3034 let resolved_fg = resolved.fg.unwrap();
3035 assert!(
3037 resolved_fg.r() >= base_fg.r(),
3038 "screen should lighten: {} vs {}",
3039 resolved_fg.r(),
3040 base_fg.r()
3041 );
3042 }
3043
3044 #[test]
3045 fn effect_resolver_gradient_sweep() {
3046 let gradient = Gradient::new(vec![
3047 (0.0, PackedRgba::rgb(255, 0, 0)),
3048 (1.0, PackedRgba::rgb(0, 0, 255)),
3049 ]);
3050 let theme = TableTheme::graphite().with_effect(TableEffectRule::new(
3051 TableEffectTarget::AllRows,
3052 TableEffect::GradientSweep {
3053 gradient,
3054 speed: 1.0,
3055 phase_offset: 0.0,
3056 },
3057 ));
3058 let resolver = theme.effect_resolver();
3059 let base = Style::new().fg(PackedRgba::BLACK);
3060 let resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.0);
3061 let fg = resolved.fg.unwrap();
3063 assert_eq!(fg.r(), 255);
3064 assert_eq!(fg.b(), 0);
3065 }
3066
3067 #[test]
3068 fn effect_resolver_breathing_glow_with_asymmetry() {
3069 let theme = TableTheme::graphite().with_effect(TableEffectRule::new(
3070 TableEffectTarget::AllRows,
3071 TableEffect::BreathingGlow {
3072 fg: PackedRgba::rgb(255, 255, 255),
3073 bg: PackedRgba::rgb(50, 50, 50),
3074 intensity: 1.0,
3075 speed: 1.0,
3076 phase_offset: 0.0,
3077 asymmetry: 0.5,
3078 },
3079 ));
3080 let resolver = theme.effect_resolver();
3081 let base = Style::new()
3082 .fg(PackedRgba::rgb(100, 100, 100))
3083 .bg(PackedRgba::rgb(20, 20, 20));
3084 let _resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.25);
3086 }
3087
3088 #[test]
3089 fn apply_channel_none_base_uses_effect() {
3090 let result = apply_channel(
3091 None,
3092 Some(PackedRgba::rgb(100, 200, 50)),
3093 1.0,
3094 BlendMode::Replace,
3095 );
3096 assert!(result.is_some());
3097 let c = result.unwrap();
3098 assert_eq!(c.r(), 100);
3099 assert_eq!(c.g(), 200);
3100 assert_eq!(c.b(), 50);
3101 }
3102
3103 #[test]
3104 fn apply_channel_none_effect_returns_none() {
3105 let result = apply_channel(Some(PackedRgba::RED), None, 1.0, BlendMode::Replace);
3106 assert!(result.is_none());
3107 }
3108
3109 #[test]
3110 fn spec_round_trip_preserves_theme() {
3111 let theme = TableTheme::aurora()
3112 .with_padding(3)
3113 .with_column_gap(2)
3114 .with_row_height(2)
3115 .with_preset_id(Some(TablePresetId::Aurora));
3116 let spec = TableThemeSpec::from_theme(&theme);
3117 let restored = spec.into_theme();
3118 assert_eq!(restored.padding, 3);
3119 assert_eq!(restored.column_gap, 2);
3120 assert_eq!(restored.row_height, 2);
3121 assert_eq!(restored.preset_id, Some(TablePresetId::Aurora));
3122 assert_eq!(restored.style_hash(), theme.style_hash());
3123 }
3124
3125 #[test]
3126 fn spec_round_trip_with_effects() {
3127 let theme = TableTheme::neon().with_effect(TableEffectRule::new(
3128 TableEffectTarget::Row(5),
3129 TableEffect::Pulse {
3130 fg_a: PackedRgba::rgb(10, 20, 30),
3131 fg_b: PackedRgba::rgb(40, 50, 60),
3132 bg_a: PackedRgba::rgb(1, 2, 3),
3133 bg_b: PackedRgba::rgb(4, 5, 6),
3134 speed: 2.0,
3135 phase_offset: 0.3,
3136 },
3137 ));
3138 let spec = TableThemeSpec::from_theme(&theme);
3139 let restored = spec.into_theme();
3140 assert_eq!(restored.effects.len(), 1);
3141 assert_eq!(restored.effects_hash(), theme.effects_hash());
3142 }
3143
3144 #[test]
3145 fn spec_validate_rejects_bad_version() {
3146 let mut spec = base_spec();
3147 spec.version = 99;
3148 let err = spec.validate().expect_err("expected version error");
3149 assert_eq!(err.field, "version");
3150 }
3151
3152 #[test]
3153 fn spec_validate_rejects_row_height_zero() {
3154 let mut spec = base_spec();
3155 spec.row_height = 0;
3156 let err = spec.validate().expect_err("expected row_height error");
3157 assert_eq!(err.field, "row_height");
3158 }
3159
3160 #[test]
3161 fn spec_validate_rejects_column_gap_overflow() {
3162 let mut spec = base_spec();
3163 spec.column_gap = TABLE_THEME_SPEC_MAX_COLUMN_GAP + 1;
3164 let err = spec.validate().expect_err("expected column_gap error");
3165 assert_eq!(err.field, "column_gap");
3166 }
3167
3168 #[test]
3169 fn spec_validate_rejects_inverted_column_range() {
3170 let mut spec = base_spec();
3171 let mut rule = sample_rule();
3172 rule.target = TableEffectTarget::ColumnRange { start: 5, end: 2 };
3173 spec.effects = vec![rule];
3174 let err = spec.validate().expect_err("expected column range error");
3175 assert!(err.field.contains("target"), "field: {}", err.field);
3176 }
3177
3178 #[test]
3179 fn spec_validate_rejects_nan_speed() {
3180 let mut spec = base_spec();
3181 spec.effects = vec![TableEffectRuleSpec {
3182 target: TableEffectTarget::AllRows,
3183 effect: TableEffectSpec::Pulse {
3184 fg_a: RgbaSpec::new(0, 0, 0, 255),
3185 fg_b: RgbaSpec::new(0, 0, 0, 255),
3186 bg_a: RgbaSpec::new(0, 0, 0, 255),
3187 bg_b: RgbaSpec::new(0, 0, 0, 255),
3188 speed: f32::NAN,
3189 phase_offset: 0.0,
3190 },
3191 priority: 0,
3192 blend_mode: BlendMode::Replace,
3193 style_mask: StyleMask::fg_bg(),
3194 }];
3195 let err = spec.validate().expect_err("expected NaN error");
3196 assert!(err.message.contains("finite"), "error msg: {}", err.message);
3197 }
3198
3199 #[test]
3200 fn spec_validate_rejects_inf_intensity() {
3201 let mut spec = base_spec();
3202 spec.effects = vec![TableEffectRuleSpec {
3203 target: TableEffectTarget::AllRows,
3204 effect: TableEffectSpec::BreathingGlow {
3205 fg: RgbaSpec::new(0, 0, 0, 255),
3206 bg: RgbaSpec::new(0, 0, 0, 255),
3207 intensity: f32::INFINITY,
3208 speed: 1.0,
3209 phase_offset: 0.0,
3210 asymmetry: 0.0,
3211 },
3212 priority: 0,
3213 blend_mode: BlendMode::Replace,
3214 style_mask: StyleMask::fg_bg(),
3215 }];
3216 let err = spec.validate().expect_err("expected Inf error");
3217 assert!(err.message.contains("finite"), "error msg: {}", err.message);
3218 }
3219
3220 #[test]
3221 fn style_spec_round_trip() {
3222 let style = Style::new()
3223 .fg(PackedRgba::rgb(100, 150, 200))
3224 .bg(PackedRgba::rgb(10, 20, 30))
3225 .bold()
3226 .italic();
3227 let spec = StyleSpec::from_style(&style);
3228 let restored = spec.to_style();
3229 assert_eq!(restored.fg, style.fg);
3230 assert_eq!(restored.bg, style.bg);
3231 assert!(restored.has_attr(StyleFlags::BOLD));
3232 assert!(restored.has_attr(StyleFlags::ITALIC));
3233 }
3234
3235 #[test]
3236 fn gradient_spec_round_trip() {
3237 let gradient = Gradient::new(vec![
3238 (0.0, PackedRgba::rgb(255, 0, 0)),
3239 (0.5, PackedRgba::rgb(0, 255, 0)),
3240 (1.0, PackedRgba::rgb(0, 0, 255)),
3241 ]);
3242 let spec = GradientSpec::from_gradient(&gradient);
3243 let restored = spec.to_gradient();
3244 assert_eq!(restored.stops().len(), 3);
3245 assert_eq!(restored.sample(0.0), gradient.sample(0.0));
3246 assert_eq!(restored.sample(1.0), gradient.sample(1.0));
3247 }
3248
3249 #[test]
3250 fn effect_spec_round_trip_pulse() {
3251 let effect = TableEffect::Pulse {
3252 fg_a: PackedRgba::rgb(10, 20, 30),
3253 fg_b: PackedRgba::rgb(40, 50, 60),
3254 bg_a: PackedRgba::rgb(1, 2, 3),
3255 bg_b: PackedRgba::rgb(4, 5, 6),
3256 speed: 1.5,
3257 phase_offset: 0.2,
3258 };
3259 let spec = TableEffectSpec::from_effect(&effect);
3260 let _restored = spec.to_effect(); }
3262
3263 #[test]
3264 fn effect_spec_round_trip_breathing() {
3265 let effect = TableEffect::BreathingGlow {
3266 fg: PackedRgba::rgb(200, 200, 200),
3267 bg: PackedRgba::rgb(10, 10, 10),
3268 intensity: 0.7,
3269 speed: 2.0,
3270 phase_offset: 0.5,
3271 asymmetry: -0.3,
3272 };
3273 let spec = TableEffectSpec::from_effect(&effect);
3274 let _restored = spec.to_effect();
3275 }
3276
3277 #[test]
3278 fn effect_spec_round_trip_gradient_sweep() {
3279 let effect = TableEffect::GradientSweep {
3280 gradient: Gradient::new(vec![(0.0, PackedRgba::RED), (1.0, PackedRgba::BLACK)]),
3281 speed: 1.0,
3282 phase_offset: 0.0,
3283 };
3284 let spec = TableEffectSpec::from_effect(&effect);
3285 let _restored = spec.to_effect();
3286 }
3287
3288 #[test]
3289 fn effect_rule_spec_round_trip() {
3290 let rule = TableEffectRule::new(
3291 TableEffectTarget::RowRange { start: 1, end: 5 },
3292 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
3293 )
3294 .priority(3)
3295 .blend_mode(BlendMode::Screen)
3296 .style_mask(StyleMask::all());
3297 let spec = TableEffectRuleSpec::from_rule(&rule);
3298 let restored = spec.to_rule();
3299 assert_eq!(restored.priority, 3);
3300 assert_eq!(restored.blend_mode, BlendMode::Screen);
3301 assert_eq!(restored.style_mask, StyleMask::all());
3302 }
3303
3304 #[test]
3305 fn attrs_flags_round_trip_all() {
3306 let flags = StyleFlags::BOLD
3307 | StyleFlags::DIM
3308 | StyleFlags::ITALIC
3309 | StyleFlags::UNDERLINE
3310 | StyleFlags::BLINK
3311 | StyleFlags::REVERSE
3312 | StyleFlags::HIDDEN
3313 | StyleFlags::STRIKETHROUGH
3314 | StyleFlags::DOUBLE_UNDERLINE
3315 | StyleFlags::CURLY_UNDERLINE;
3316 let attrs = attrs_from_flags(flags);
3317 assert_eq!(attrs.len(), 10);
3318 let restored = flags_from_attrs(&attrs);
3319 assert_eq!(restored, Some(flags));
3320 }
3321
3322 #[test]
3323 fn flags_from_empty_attrs_returns_none() {
3324 let result = flags_from_attrs(&[]);
3325 assert!(result.is_none());
3326 }
3327
3328 #[test]
3329 fn table_theme_spec_error_display() {
3330 let err = TableThemeSpecError::new("test_field", "something went wrong");
3331 let display = format!("{}", err);
3332 assert_eq!(display, "test_field: something went wrong");
3333 let debug = format!("{:?}", err);
3334 assert!(debug.contains("TableThemeSpecError"));
3335 }
3336
3337 #[test]
3338 fn table_theme_spec_error_is_std_error() {
3339 let err = TableThemeSpecError::new("f", "m");
3340 let _: &dyn std::error::Error = &err;
3341 }
3342
3343 #[test]
3344 fn table_theme_diagnostics_clone_and_debug() {
3345 let theme = TableTheme::aurora();
3346 let diag = theme.diagnostics();
3347 let cloned = diag.clone();
3348 assert_eq!(cloned.preset_id, diag.preset_id);
3349 let debug = format!("{:?}", diag);
3350 assert!(debug.contains("TableThemeDiagnostics"));
3351 }
3352
3353 #[test]
3354 fn rgba_spec_round_trip() {
3355 let packed = PackedRgba::rgba(10, 20, 30, 40);
3356 let spec = RgbaSpec::from(packed);
3357 assert_eq!(spec.r, 10);
3358 assert_eq!(spec.g, 20);
3359 assert_eq!(spec.b, 30);
3360 assert_eq!(spec.a, 40);
3361 let restored = PackedRgba::from(spec);
3362 assert_eq!(restored, packed);
3363 }
3364
3365 #[test]
3366 fn with_builders_all_styles() {
3367 let s = Style::new().fg(PackedRgba::RED);
3368 let theme = TableTheme::graphite()
3369 .with_border(s)
3370 .with_header(s)
3371 .with_row(s)
3372 .with_row_alt(s)
3373 .with_row_selected(s)
3374 .with_row_hover(s)
3375 .with_divider(s);
3376 assert_eq!(theme.border.fg, Some(PackedRgba::RED));
3377 assert_eq!(theme.header.fg, Some(PackedRgba::RED));
3378 assert_eq!(theme.row.fg, Some(PackedRgba::RED));
3379 assert_eq!(theme.row_alt.fg, Some(PackedRgba::RED));
3380 assert_eq!(theme.row_selected.fg, Some(PackedRgba::RED));
3381 assert_eq!(theme.row_hover.fg, Some(PackedRgba::RED));
3382 assert_eq!(theme.divider.fg, Some(PackedRgba::RED));
3383 }
3384
3385 #[test]
3386 fn with_effects_replaces_all() {
3387 let theme = TableTheme::graphite()
3388 .with_effect(TableEffectRule::new(
3389 TableEffectTarget::AllRows,
3390 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
3391 ))
3392 .with_effects(vec![]);
3393 assert!(theme.effects.is_empty());
3394 }
3395
3396 #[test]
3397 fn different_presets_have_different_hashes() {
3398 let aurora = TableTheme::aurora().style_hash();
3399 let neon = TableTheme::neon().style_hash();
3400 let graphite = TableTheme::graphite().style_hash();
3401 assert_ne!(aurora, neon);
3402 assert_ne!(aurora, graphite);
3403 assert_ne!(neon, graphite);
3404 }
3405
3406 #[test]
3407 fn all_presets_construct_without_panic() {
3408 let presets = [
3409 TablePresetId::Aurora,
3410 TablePresetId::Graphite,
3411 TablePresetId::Neon,
3412 TablePresetId::Slate,
3413 TablePresetId::Solar,
3414 TablePresetId::Orchard,
3415 TablePresetId::Paper,
3416 TablePresetId::Midnight,
3417 TablePresetId::TerminalClassic,
3418 ];
3419 for id in presets {
3420 let theme = TableTheme::preset(id);
3421 assert_eq!(theme.preset_id, Some(id));
3422 assert_eq!(theme.padding, 1);
3423 assert_eq!(theme.column_gap, 1);
3424 assert_eq!(theme.row_height, 1);
3425 assert!(theme.effects.is_empty());
3426 }
3427 }
3428
3429 #[test]
3430 fn table_preset_id_traits() {
3431 let id = TablePresetId::Aurora;
3432 let debug = format!("{:?}", id);
3433 assert!(debug.contains("Aurora"));
3434 let cloned = id;
3435 assert_eq!(id, cloned);
3436 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3438 id.hash(&mut hasher);
3439 }
3440
3441 #[test]
3442 fn table_section_traits() {
3443 let s = TableSection::Footer;
3444 let debug = format!("{:?}", s);
3445 assert!(debug.contains("Footer"));
3446 assert_eq!(s, TableSection::Footer);
3447 assert_ne!(s, TableSection::Header);
3448 }
3449
3450 #[test]
3451 fn table_effect_target_traits() {
3452 let t = TableEffectTarget::AllCells;
3453 let debug = format!("{:?}", t);
3454 assert!(debug.contains("AllCells"));
3455 assert_eq!(t, TableEffectTarget::AllCells);
3456 }
3457
3458 #[test]
3459 fn table_effect_scope_traits() {
3460 let s = TableEffectScope::section(TableSection::Body);
3461 let debug = format!("{:?}", s);
3462 assert!(debug.contains("Body"));
3463 let cloned = s;
3464 assert_eq!(s, cloned);
3465 }
3466
3467 #[test]
3468 fn style_mask_traits() {
3469 let m = StyleMask::all();
3470 let debug = format!("{:?}", m);
3471 assert!(debug.contains("StyleMask"));
3472 let cloned = m;
3473 assert_eq!(m, cloned);
3474 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3476 m.hash(&mut hasher);
3477 }
3478
3479 #[test]
3480 fn style_attr_all_variants() {
3481 let attrs = [
3482 StyleAttr::Bold,
3483 StyleAttr::Dim,
3484 StyleAttr::Italic,
3485 StyleAttr::Underline,
3486 StyleAttr::Blink,
3487 StyleAttr::Reverse,
3488 StyleAttr::Hidden,
3489 StyleAttr::Strikethrough,
3490 StyleAttr::DoubleUnderline,
3491 StyleAttr::CurlyUnderline,
3492 ];
3493 for attr in &attrs {
3494 let debug = format!("{:?}", attr);
3495 assert!(!debug.is_empty());
3496 }
3497 assert_eq!(attrs[0], StyleAttr::Bold);
3498 assert_ne!(attrs[0], attrs[1]);
3499 }
3500
3501 #[test]
3502 fn skew_phase_clamps_extreme_asymmetry() {
3503 for asym in [-1.5, -0.9, 0.9, 1.5] {
3505 for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
3506 let result = skew_phase(t, asym);
3507 assert!(
3508 (-0.01..=1.01).contains(&result),
3509 "skew_phase({t}, {asym}) = {result}"
3510 );
3511 }
3512 }
3513 }
3514
3515 #[test]
3516 fn normalize_phase_negative_large() {
3517 let result = normalize_phase(-100.7);
3518 assert!((0.0..1.0).contains(&result), "result: {result}");
3519 }
3520
3521 #[test]
3522 fn table_theme_clone() {
3523 let theme = TableTheme::aurora().with_effect(TableEffectRule::new(
3524 TableEffectTarget::AllRows,
3525 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
3526 ));
3527 let cloned = theme.clone();
3528 assert_eq!(cloned.style_hash(), theme.style_hash());
3529 assert_eq!(cloned.effects_hash(), theme.effects_hash());
3530 assert_eq!(cloned.preset_id, theme.preset_id);
3531 }
3532}