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 } => {
794 if start > end {
795 return Err(TableThemeSpecError::new(
796 format!("{base}.row_range"),
797 "start must be <= end",
798 ));
799 }
800 }
801 TableEffectTarget::ColumnRange { start, end } => {
802 if start > end {
803 return Err(TableThemeSpecError::new(
804 format!("{base}.column_range"),
805 "start must be <= end",
806 ));
807 }
808 }
809 _ => {}
810 }
811 Ok(())
812}
813
814fn validate_effect_rule(rule: &TableEffectRuleSpec, idx: usize) -> Result<(), TableThemeSpecError> {
815 validate_effect_target(&rule.target, idx)?;
816 let base = format!("effects[{idx}].effect");
817 match &rule.effect {
818 TableEffectSpec::Pulse {
819 speed,
820 phase_offset,
821 ..
822 } => {
823 validate_f32_range(
824 format!("{base}.speed"),
825 *speed,
826 0.0,
827 TABLE_THEME_SPEC_MAX_SPEED,
828 )?;
829 validate_f32_range(
830 format!("{base}.phase_offset"),
831 *phase_offset,
832 0.0,
833 TABLE_THEME_SPEC_MAX_PHASE,
834 )?;
835 }
836 TableEffectSpec::BreathingGlow {
837 intensity,
838 speed,
839 phase_offset,
840 asymmetry,
841 ..
842 } => {
843 validate_f32_range(
844 format!("{base}.intensity"),
845 *intensity,
846 0.0,
847 TABLE_THEME_SPEC_MAX_INTENSITY,
848 )?;
849 validate_f32_range(
850 format!("{base}.speed"),
851 *speed,
852 0.0,
853 TABLE_THEME_SPEC_MAX_SPEED,
854 )?;
855 validate_f32_range(
856 format!("{base}.phase_offset"),
857 *phase_offset,
858 0.0,
859 TABLE_THEME_SPEC_MAX_PHASE,
860 )?;
861 validate_f32_range(
862 format!("{base}.asymmetry"),
863 *asymmetry,
864 -TABLE_THEME_SPEC_MAX_ASYMMETRY,
865 TABLE_THEME_SPEC_MAX_ASYMMETRY,
866 )?;
867 }
868 TableEffectSpec::GradientSweep {
869 gradient,
870 speed,
871 phase_offset,
872 } => {
873 validate_gradient_spec(gradient, &base)?;
874 validate_f32_range(
875 format!("{base}.speed"),
876 *speed,
877 0.0,
878 TABLE_THEME_SPEC_MAX_SPEED,
879 )?;
880 validate_f32_range(
881 format!("{base}.phase_offset"),
882 *phase_offset,
883 0.0,
884 TABLE_THEME_SPEC_MAX_PHASE,
885 )?;
886 }
887 }
888 Ok(())
889}
890
891fn validate_gradient_spec(gradient: &GradientSpec, base: &str) -> Result<(), TableThemeSpecError> {
892 let count = gradient.stops.len();
893 if !(TABLE_THEME_SPEC_MIN_GRADIENT_STOPS..=TABLE_THEME_SPEC_MAX_GRADIENT_STOPS).contains(&count)
894 {
895 return Err(TableThemeSpecError::new(
896 format!("{base}.gradient.stops"),
897 format!(
898 "stop count {} outside range [{}..={}]",
899 count, TABLE_THEME_SPEC_MIN_GRADIENT_STOPS, TABLE_THEME_SPEC_MAX_GRADIENT_STOPS
900 ),
901 ));
902 }
903 for (idx, stop) in gradient.stops.iter().enumerate() {
904 validate_f32_range(
905 format!("{base}.gradient.stops[{idx}].pos"),
906 stop.pos,
907 0.0,
908 1.0,
909 )?;
910 }
911 Ok(())
912}
913
914fn validate_f32_range(
915 field: impl Into<String>,
916 value: f32,
917 min: f32,
918 max: f32,
919) -> Result<(), TableThemeSpecError> {
920 if !value.is_finite() {
921 return Err(TableThemeSpecError::new(field, "value must be finite"));
922 }
923 if value < min || value > max {
924 return Err(TableThemeSpecError::new(
925 field,
926 format!("value {} outside range [{min}..={max}]", value),
927 ));
928 }
929 Ok(())
930}
931
932impl StyleSpec {
933 #[must_use]
934 pub fn from_style(style: &Style) -> Self {
935 Self {
936 fg: style.fg.map(RgbaSpec::from),
937 bg: style.bg.map(RgbaSpec::from),
938 underline: style.underline_color.map(RgbaSpec::from),
939 attrs: style.attrs.map(attrs_from_flags).unwrap_or_default(),
940 }
941 }
942
943 #[must_use]
944 pub fn to_style(&self) -> Style {
945 let mut style = Style::new();
946 style.fg = self.fg.map(PackedRgba::from);
947 style.bg = self.bg.map(PackedRgba::from);
948 style.underline_color = self.underline.map(PackedRgba::from);
949 style.attrs = flags_from_attrs(&self.attrs);
950 style
951 }
952}
953
954impl GradientSpec {
955 #[must_use]
956 pub fn from_gradient(gradient: &Gradient) -> Self {
957 Self {
958 stops: gradient
959 .stops()
960 .iter()
961 .map(|(pos, color)| GradientStopSpec {
962 pos: *pos,
963 color: RgbaSpec::from(*color),
964 })
965 .collect(),
966 }
967 }
968
969 #[must_use]
970 pub fn to_gradient(&self) -> Gradient {
971 Gradient::new(
972 self.stops
973 .iter()
974 .map(|stop| (stop.pos, PackedRgba::from(stop.color)))
975 .collect(),
976 )
977 }
978}
979
980impl TableEffectSpec {
981 #[must_use]
982 pub fn from_effect(effect: &TableEffect) -> Self {
983 match effect {
984 TableEffect::Pulse {
985 fg_a,
986 fg_b,
987 bg_a,
988 bg_b,
989 speed,
990 phase_offset,
991 } => Self::Pulse {
992 fg_a: (*fg_a).into(),
993 fg_b: (*fg_b).into(),
994 bg_a: (*bg_a).into(),
995 bg_b: (*bg_b).into(),
996 speed: *speed,
997 phase_offset: *phase_offset,
998 },
999 TableEffect::BreathingGlow {
1000 fg,
1001 bg,
1002 intensity,
1003 speed,
1004 phase_offset,
1005 asymmetry,
1006 } => Self::BreathingGlow {
1007 fg: (*fg).into(),
1008 bg: (*bg).into(),
1009 intensity: *intensity,
1010 speed: *speed,
1011 phase_offset: *phase_offset,
1012 asymmetry: *asymmetry,
1013 },
1014 TableEffect::GradientSweep {
1015 gradient,
1016 speed,
1017 phase_offset,
1018 } => Self::GradientSweep {
1019 gradient: GradientSpec::from_gradient(gradient),
1020 speed: *speed,
1021 phase_offset: *phase_offset,
1022 },
1023 }
1024 }
1025
1026 #[must_use]
1027 pub fn to_effect(&self) -> TableEffect {
1028 match self {
1029 TableEffectSpec::Pulse {
1030 fg_a,
1031 fg_b,
1032 bg_a,
1033 bg_b,
1034 speed,
1035 phase_offset,
1036 } => TableEffect::Pulse {
1037 fg_a: (*fg_a).into(),
1038 fg_b: (*fg_b).into(),
1039 bg_a: (*bg_a).into(),
1040 bg_b: (*bg_b).into(),
1041 speed: *speed,
1042 phase_offset: *phase_offset,
1043 },
1044 TableEffectSpec::BreathingGlow {
1045 fg,
1046 bg,
1047 intensity,
1048 speed,
1049 phase_offset,
1050 asymmetry,
1051 } => TableEffect::BreathingGlow {
1052 fg: (*fg).into(),
1053 bg: (*bg).into(),
1054 intensity: *intensity,
1055 speed: *speed,
1056 phase_offset: *phase_offset,
1057 asymmetry: *asymmetry,
1058 },
1059 TableEffectSpec::GradientSweep {
1060 gradient,
1061 speed,
1062 phase_offset,
1063 } => TableEffect::GradientSweep {
1064 gradient: gradient.to_gradient(),
1065 speed: *speed,
1066 phase_offset: *phase_offset,
1067 },
1068 }
1069 }
1070}
1071
1072impl TableEffectRuleSpec {
1073 #[must_use]
1074 pub fn from_rule(rule: &TableEffectRule) -> Self {
1075 Self {
1076 target: rule.target,
1077 effect: TableEffectSpec::from_effect(&rule.effect),
1078 priority: rule.priority,
1079 blend_mode: rule.blend_mode,
1080 style_mask: rule.style_mask,
1081 }
1082 }
1083
1084 #[must_use]
1085 pub fn to_rule(&self) -> TableEffectRule {
1086 TableEffectRule {
1087 target: self.target,
1088 effect: self.effect.to_effect(),
1089 priority: self.priority,
1090 blend_mode: self.blend_mode,
1091 style_mask: self.style_mask,
1092 }
1093 }
1094}
1095
1096fn attrs_from_flags(flags: StyleFlags) -> Vec<StyleAttr> {
1097 let mut attrs = Vec::new();
1098 if flags.contains(StyleFlags::BOLD) {
1099 attrs.push(StyleAttr::Bold);
1100 }
1101 if flags.contains(StyleFlags::DIM) {
1102 attrs.push(StyleAttr::Dim);
1103 }
1104 if flags.contains(StyleFlags::ITALIC) {
1105 attrs.push(StyleAttr::Italic);
1106 }
1107 if flags.contains(StyleFlags::UNDERLINE) {
1108 attrs.push(StyleAttr::Underline);
1109 }
1110 if flags.contains(StyleFlags::BLINK) {
1111 attrs.push(StyleAttr::Blink);
1112 }
1113 if flags.contains(StyleFlags::REVERSE) {
1114 attrs.push(StyleAttr::Reverse);
1115 }
1116 if flags.contains(StyleFlags::HIDDEN) {
1117 attrs.push(StyleAttr::Hidden);
1118 }
1119 if flags.contains(StyleFlags::STRIKETHROUGH) {
1120 attrs.push(StyleAttr::Strikethrough);
1121 }
1122 if flags.contains(StyleFlags::DOUBLE_UNDERLINE) {
1123 attrs.push(StyleAttr::DoubleUnderline);
1124 }
1125 if flags.contains(StyleFlags::CURLY_UNDERLINE) {
1126 attrs.push(StyleAttr::CurlyUnderline);
1127 }
1128 attrs
1129}
1130
1131fn flags_from_attrs(attrs: &[StyleAttr]) -> Option<StyleFlags> {
1132 if attrs.is_empty() {
1133 return None;
1134 }
1135 let mut flags = StyleFlags::NONE;
1136 for attr in attrs {
1137 match attr {
1138 StyleAttr::Bold => flags.insert(StyleFlags::BOLD),
1139 StyleAttr::Dim => flags.insert(StyleFlags::DIM),
1140 StyleAttr::Italic => flags.insert(StyleFlags::ITALIC),
1141 StyleAttr::Underline => flags.insert(StyleFlags::UNDERLINE),
1142 StyleAttr::Blink => flags.insert(StyleFlags::BLINK),
1143 StyleAttr::Reverse => flags.insert(StyleFlags::REVERSE),
1144 StyleAttr::Hidden => flags.insert(StyleFlags::HIDDEN),
1145 StyleAttr::Strikethrough => flags.insert(StyleFlags::STRIKETHROUGH),
1146 StyleAttr::DoubleUnderline => flags.insert(StyleFlags::DOUBLE_UNDERLINE),
1147 StyleAttr::CurlyUnderline => flags.insert(StyleFlags::CURLY_UNDERLINE),
1148 }
1149 }
1150 if flags.is_empty() { None } else { Some(flags) }
1151}
1152
1153struct ThemeStyles {
1154 border: Style,
1155 header: Style,
1156 row: Style,
1157 row_alt: Style,
1158 row_selected: Style,
1159 row_hover: Style,
1160 divider: Style,
1161}
1162
1163impl TableTheme {
1164 #[must_use]
1166 pub const fn effect_resolver(&self) -> TableEffectResolver<'_> {
1167 TableEffectResolver::new(self)
1168 }
1169
1170 #[must_use]
1172 pub fn preset(preset: TablePresetId) -> Self {
1173 match preset {
1174 TablePresetId::Aurora => Self::aurora(),
1175 TablePresetId::Graphite => Self::graphite(),
1176 TablePresetId::Neon => Self::neon(),
1177 TablePresetId::Slate => Self::slate(),
1178 TablePresetId::Solar => Self::solar(),
1179 TablePresetId::Orchard => Self::orchard(),
1180 TablePresetId::Paper => Self::paper(),
1181 TablePresetId::Midnight => Self::midnight(),
1182 TablePresetId::TerminalClassic => Self::terminal_classic(),
1183 }
1184 }
1185
1186 #[must_use]
1188 pub fn with_border(mut self, border: Style) -> Self {
1189 self.border = border;
1190 self
1191 }
1192
1193 #[must_use]
1195 pub fn with_header(mut self, header: Style) -> Self {
1196 self.header = header;
1197 self
1198 }
1199
1200 #[must_use]
1202 pub fn with_row(mut self, row: Style) -> Self {
1203 self.row = row;
1204 self
1205 }
1206
1207 #[must_use]
1209 pub fn with_row_alt(mut self, row_alt: Style) -> Self {
1210 self.row_alt = row_alt;
1211 self
1212 }
1213
1214 #[must_use]
1216 pub fn with_row_selected(mut self, row_selected: Style) -> Self {
1217 self.row_selected = row_selected;
1218 self
1219 }
1220
1221 #[must_use]
1223 pub fn with_row_hover(mut self, row_hover: Style) -> Self {
1224 self.row_hover = row_hover;
1225 self
1226 }
1227
1228 #[must_use]
1230 pub fn with_divider(mut self, divider: Style) -> Self {
1231 self.divider = divider;
1232 self
1233 }
1234
1235 #[must_use]
1237 pub fn with_padding(mut self, padding: u8) -> Self {
1238 self.padding = padding;
1239 self
1240 }
1241
1242 #[must_use]
1244 pub fn with_column_gap(mut self, column_gap: u8) -> Self {
1245 self.column_gap = column_gap;
1246 self
1247 }
1248
1249 #[must_use]
1251 pub fn with_row_height(mut self, row_height: u8) -> Self {
1252 self.row_height = row_height;
1253 self
1254 }
1255
1256 #[must_use]
1258 pub fn with_effects(mut self, effects: Vec<TableEffectRule>) -> Self {
1259 self.effects = effects;
1260 self
1261 }
1262
1263 #[must_use]
1265 pub fn with_effect(mut self, effect: TableEffectRule) -> Self {
1266 self.effects.push(effect);
1267 self
1268 }
1269
1270 #[must_use]
1272 pub fn clear_effects(mut self) -> Self {
1273 self.effects.clear();
1274 self
1275 }
1276
1277 #[must_use]
1279 pub fn with_preset_id(mut self, preset_id: Option<TablePresetId>) -> Self {
1280 self.preset_id = preset_id;
1281 self
1282 }
1283
1284 #[must_use]
1286 pub fn aurora() -> Self {
1287 Self::build(
1288 TablePresetId::Aurora,
1289 ThemeStyles {
1290 border: Style::new().fg(PackedRgba::rgb(130, 170, 210)),
1291 header: Style::new()
1292 .fg(PackedRgba::rgb(250, 250, 255))
1293 .bg(PackedRgba::rgb(70, 100, 140))
1294 .bold(),
1295 row: Style::new().fg(PackedRgba::rgb(230, 235, 245)),
1296 row_alt: Style::new()
1297 .fg(PackedRgba::rgb(230, 235, 245))
1298 .bg(PackedRgba::rgb(28, 36, 54)),
1299 row_selected: Style::new()
1300 .fg(PackedRgba::rgb(255, 255, 255))
1301 .bg(PackedRgba::rgb(50, 90, 140))
1302 .bold(),
1303 row_hover: Style::new()
1304 .fg(PackedRgba::rgb(240, 245, 255))
1305 .bg(PackedRgba::rgb(40, 70, 110)),
1306 divider: Style::new().fg(PackedRgba::rgb(90, 120, 160)),
1307 },
1308 )
1309 }
1310
1311 #[must_use]
1313 pub fn graphite() -> Self {
1314 Self::build(
1315 TablePresetId::Graphite,
1316 ThemeStyles {
1317 border: Style::new().fg(PackedRgba::rgb(140, 140, 140)),
1318 header: Style::new()
1319 .fg(PackedRgba::rgb(240, 240, 240))
1320 .bg(PackedRgba::rgb(70, 70, 70))
1321 .bold(),
1322 row: Style::new().fg(PackedRgba::rgb(220, 220, 220)),
1323 row_alt: Style::new()
1324 .fg(PackedRgba::rgb(220, 220, 220))
1325 .bg(PackedRgba::rgb(35, 35, 35)),
1326 row_selected: Style::new()
1327 .fg(PackedRgba::rgb(255, 255, 255))
1328 .bg(PackedRgba::rgb(90, 90, 90)),
1329 row_hover: Style::new()
1330 .fg(PackedRgba::rgb(245, 245, 245))
1331 .bg(PackedRgba::rgb(60, 60, 60)),
1332 divider: Style::new().fg(PackedRgba::rgb(120, 120, 120)),
1333 },
1334 )
1335 }
1336
1337 #[must_use]
1339 pub fn neon() -> Self {
1340 Self::build(
1341 TablePresetId::Neon,
1342 ThemeStyles {
1343 border: Style::new().fg(PackedRgba::rgb(120, 255, 230)),
1344 header: Style::new()
1345 .fg(PackedRgba::rgb(10, 10, 15))
1346 .bg(PackedRgba::rgb(0, 255, 200))
1347 .bold(),
1348 row: Style::new().fg(PackedRgba::rgb(210, 255, 245)),
1349 row_alt: Style::new()
1350 .fg(PackedRgba::rgb(210, 255, 245))
1351 .bg(PackedRgba::rgb(10, 20, 30)),
1352 row_selected: Style::new()
1353 .fg(PackedRgba::rgb(5, 5, 10))
1354 .bg(PackedRgba::rgb(255, 0, 200))
1355 .bold(),
1356 row_hover: Style::new()
1357 .fg(PackedRgba::rgb(0, 10, 15))
1358 .bg(PackedRgba::rgb(0, 200, 255)),
1359 divider: Style::new().fg(PackedRgba::rgb(80, 220, 200)),
1360 },
1361 )
1362 }
1363
1364 #[must_use]
1366 pub fn slate() -> Self {
1367 Self::build(
1368 TablePresetId::Slate,
1369 ThemeStyles {
1370 border: Style::new().fg(PackedRgba::rgb(120, 130, 140)),
1371 header: Style::new()
1372 .fg(PackedRgba::rgb(230, 235, 240))
1373 .bg(PackedRgba::rgb(60, 70, 80))
1374 .bold(),
1375 row: Style::new().fg(PackedRgba::rgb(210, 215, 220)),
1376 row_alt: Style::new()
1377 .fg(PackedRgba::rgb(210, 215, 220))
1378 .bg(PackedRgba::rgb(30, 35, 40)),
1379 row_selected: Style::new()
1380 .fg(PackedRgba::rgb(255, 255, 255))
1381 .bg(PackedRgba::rgb(80, 90, 110)),
1382 row_hover: Style::new()
1383 .fg(PackedRgba::rgb(235, 240, 245))
1384 .bg(PackedRgba::rgb(50, 60, 70)),
1385 divider: Style::new().fg(PackedRgba::rgb(110, 120, 130)),
1386 },
1387 )
1388 }
1389
1390 #[must_use]
1392 pub fn solar() -> Self {
1393 Self::build(
1394 TablePresetId::Solar,
1395 ThemeStyles {
1396 border: Style::new().fg(PackedRgba::rgb(200, 170, 120)),
1397 header: Style::new()
1398 .fg(PackedRgba::rgb(30, 25, 10))
1399 .bg(PackedRgba::rgb(255, 200, 90))
1400 .bold(),
1401 row: Style::new().fg(PackedRgba::rgb(240, 220, 180)),
1402 row_alt: Style::new()
1403 .fg(PackedRgba::rgb(240, 220, 180))
1404 .bg(PackedRgba::rgb(60, 40, 20)),
1405 row_selected: Style::new()
1406 .fg(PackedRgba::rgb(20, 10, 0))
1407 .bg(PackedRgba::rgb(255, 140, 60))
1408 .bold(),
1409 row_hover: Style::new()
1410 .fg(PackedRgba::rgb(20, 10, 0))
1411 .bg(PackedRgba::rgb(220, 120, 40)),
1412 divider: Style::new().fg(PackedRgba::rgb(170, 140, 90)),
1413 },
1414 )
1415 }
1416
1417 #[must_use]
1419 pub fn orchard() -> Self {
1420 Self::build(
1421 TablePresetId::Orchard,
1422 ThemeStyles {
1423 border: Style::new().fg(PackedRgba::rgb(140, 180, 120)),
1424 header: Style::new()
1425 .fg(PackedRgba::rgb(20, 40, 20))
1426 .bg(PackedRgba::rgb(120, 200, 120))
1427 .bold(),
1428 row: Style::new().fg(PackedRgba::rgb(210, 235, 210)),
1429 row_alt: Style::new()
1430 .fg(PackedRgba::rgb(210, 235, 210))
1431 .bg(PackedRgba::rgb(30, 60, 40)),
1432 row_selected: Style::new()
1433 .fg(PackedRgba::rgb(15, 30, 15))
1434 .bg(PackedRgba::rgb(160, 230, 140))
1435 .bold(),
1436 row_hover: Style::new()
1437 .fg(PackedRgba::rgb(15, 30, 15))
1438 .bg(PackedRgba::rgb(130, 210, 120)),
1439 divider: Style::new().fg(PackedRgba::rgb(100, 150, 100)),
1440 },
1441 )
1442 }
1443
1444 #[must_use]
1446 pub fn paper() -> Self {
1447 Self::build(
1448 TablePresetId::Paper,
1449 ThemeStyles {
1450 border: Style::new().fg(PackedRgba::rgb(120, 110, 100)),
1451 header: Style::new()
1452 .fg(PackedRgba::rgb(30, 30, 30))
1453 .bg(PackedRgba::rgb(230, 220, 200))
1454 .bold(),
1455 row: Style::new()
1456 .fg(PackedRgba::rgb(40, 40, 40))
1457 .bg(PackedRgba::rgb(245, 240, 230)),
1458 row_alt: Style::new()
1459 .fg(PackedRgba::rgb(40, 40, 40))
1460 .bg(PackedRgba::rgb(235, 230, 220)),
1461 row_selected: Style::new()
1462 .fg(PackedRgba::rgb(10, 10, 10))
1463 .bg(PackedRgba::rgb(255, 245, 210))
1464 .bold(),
1465 row_hover: Style::new()
1466 .fg(PackedRgba::rgb(20, 20, 20))
1467 .bg(PackedRgba::rgb(245, 235, 205)),
1468 divider: Style::new().fg(PackedRgba::rgb(140, 130, 120)),
1469 },
1470 )
1471 }
1472
1473 #[must_use]
1475 pub fn midnight() -> Self {
1476 Self::build(
1477 TablePresetId::Midnight,
1478 ThemeStyles {
1479 border: Style::new().fg(PackedRgba::rgb(80, 100, 130)),
1480 header: Style::new()
1481 .fg(PackedRgba::rgb(220, 230, 255))
1482 .bg(PackedRgba::rgb(30, 40, 70))
1483 .bold(),
1484 row: Style::new().fg(PackedRgba::rgb(200, 210, 230)),
1485 row_alt: Style::new()
1486 .fg(PackedRgba::rgb(200, 210, 230))
1487 .bg(PackedRgba::rgb(15, 20, 35)),
1488 row_selected: Style::new()
1489 .fg(PackedRgba::rgb(255, 255, 255))
1490 .bg(PackedRgba::rgb(60, 80, 120))
1491 .bold(),
1492 row_hover: Style::new()
1493 .fg(PackedRgba::rgb(240, 240, 255))
1494 .bg(PackedRgba::rgb(45, 60, 90)),
1495 divider: Style::new().fg(PackedRgba::rgb(100, 120, 150)),
1496 },
1497 )
1498 }
1499
1500 #[must_use]
1502 pub fn terminal_classic() -> Self {
1503 Self::terminal_classic_for(ColorProfile::detect())
1504 }
1505
1506 #[must_use]
1508 pub fn terminal_classic_for(profile: ColorProfile) -> Self {
1509 let border = classic_color(profile, (160, 160, 160), Ansi16::BrightBlack);
1510 let header_fg = classic_color(profile, (245, 245, 245), Ansi16::BrightWhite);
1511 let header_bg = classic_color(profile, (0, 90, 140), Ansi16::Blue);
1512 let row_fg = classic_color(profile, (230, 230, 230), Ansi16::White);
1513 let row_alt_bg = classic_color(profile, (30, 30, 30), Ansi16::Black);
1514 let selected_bg = classic_color(profile, (160, 90, 10), Ansi16::Yellow);
1515 let hover_bg = classic_color(profile, (70, 70, 70), Ansi16::BrightBlack);
1516 let divider = classic_color(profile, (120, 120, 120), Ansi16::BrightBlack);
1517
1518 Self::build(
1519 TablePresetId::TerminalClassic,
1520 ThemeStyles {
1521 border: Style::new().fg(border),
1522 header: Style::new().fg(header_fg).bg(header_bg).bold(),
1523 row: Style::new().fg(row_fg),
1524 row_alt: Style::new().fg(row_fg).bg(row_alt_bg),
1525 row_selected: Style::new().fg(PackedRgba::BLACK).bg(selected_bg).bold(),
1526 row_hover: Style::new().fg(PackedRgba::WHITE).bg(hover_bg),
1527 divider: Style::new().fg(divider),
1528 },
1529 )
1530 }
1531
1532 fn build(preset_id: TablePresetId, styles: ThemeStyles) -> Self {
1533 Self {
1534 border: styles.border,
1535 header: styles.header,
1536 row: styles.row,
1537 row_alt: styles.row_alt,
1538 row_selected: styles.row_selected,
1539 row_hover: styles.row_hover,
1540 divider: styles.divider,
1541 padding: 1,
1542 column_gap: 1,
1543 row_height: 1,
1544 effects: Vec::new(),
1545 preset_id: Some(preset_id),
1546 }
1547 }
1548
1549 #[must_use]
1551 pub fn diagnostics(&self) -> TableThemeDiagnostics {
1552 TableThemeDiagnostics {
1553 preset_id: self.preset_id,
1554 style_hash: self.style_hash(),
1555 effects_hash: self.effects_hash(),
1556 effect_count: self.effects.len(),
1557 padding: self.padding,
1558 column_gap: self.column_gap,
1559 row_height: self.row_height,
1560 }
1561 }
1562
1563 #[must_use]
1565 pub fn style_hash(&self) -> u64 {
1566 let mut hasher = StableHasher::new();
1567 hash_style(&self.border, &mut hasher);
1568 hash_style(&self.header, &mut hasher);
1569 hash_style(&self.row, &mut hasher);
1570 hash_style(&self.row_alt, &mut hasher);
1571 hash_style(&self.row_selected, &mut hasher);
1572 hash_style(&self.row_hover, &mut hasher);
1573 hash_style(&self.divider, &mut hasher);
1574 hash_u8(self.padding, &mut hasher);
1575 hash_u8(self.column_gap, &mut hasher);
1576 hash_u8(self.row_height, &mut hasher);
1577 hash_preset(self.preset_id, &mut hasher);
1578 hasher.finish()
1579 }
1580
1581 #[must_use]
1583 pub fn effects_hash(&self) -> u64 {
1584 let mut hasher = StableHasher::new();
1585 hash_usize(self.effects.len(), &mut hasher);
1586 for rule in &self.effects {
1587 hash_effect_rule(rule, &mut hasher);
1588 }
1589 hasher.finish()
1590 }
1591}
1592
1593#[derive(Clone, Copy, Debug)]
1594struct EffectSample {
1595 fg: Option<PackedRgba>,
1596 bg: Option<PackedRgba>,
1597 alpha: f32,
1598}
1599
1600#[inline]
1601fn resolve_effects_for_scope(
1602 theme: &TableTheme,
1603 base: Style,
1604 scope: TableEffectScope,
1605 phase: f32,
1606) -> Style {
1607 if theme.effects.is_empty() {
1608 return base;
1609 }
1610
1611 let mut min_priority = u8::MAX;
1612 let mut max_priority = 0;
1613 for rule in &theme.effects {
1614 min_priority = min_priority.min(rule.priority);
1615 max_priority = max_priority.max(rule.priority);
1616 }
1617 if min_priority == u8::MAX {
1618 return base;
1619 }
1620
1621 let mut resolved = base;
1622 for priority in min_priority..=max_priority {
1623 for rule in &theme.effects {
1624 if rule.priority != priority {
1625 continue;
1626 }
1627 if !rule.target.matches_scope(scope) {
1628 continue;
1629 }
1630 resolved = apply_effect_rule(resolved, rule, phase);
1631 }
1632 }
1633
1634 resolved
1635}
1636
1637#[inline]
1638fn apply_effect_rule(mut base: Style, rule: &TableEffectRule, phase: f32) -> Style {
1639 let sample = sample_effect(&rule.effect, phase);
1640 let alpha = sample.alpha.clamp(0.0, 1.0);
1641 if alpha <= 0.0 {
1642 return base;
1643 }
1644
1645 if rule.style_mask.fg {
1646 base.fg = apply_channel(base.fg, sample.fg, alpha, rule.blend_mode);
1647 }
1648 if rule.style_mask.bg {
1649 base.bg = apply_channel(base.bg, sample.bg, alpha, rule.blend_mode);
1650 }
1651 base
1652}
1653
1654#[inline]
1655fn apply_channel(
1656 base: Option<PackedRgba>,
1657 effect: Option<PackedRgba>,
1658 alpha: f32,
1659 blend_mode: BlendMode,
1660) -> Option<PackedRgba> {
1661 let effect = effect?;
1662 let alpha = alpha.clamp(0.0, 1.0);
1663 let result = match base {
1664 Some(base) => blend_with_alpha(base, effect, alpha, blend_mode),
1665 None => with_alpha(effect, alpha),
1666 };
1667 Some(result)
1668}
1669
1670#[inline]
1671fn blend_with_alpha(
1672 base: PackedRgba,
1673 effect: PackedRgba,
1674 alpha: f32,
1675 blend_mode: BlendMode,
1676) -> PackedRgba {
1677 let alpha = alpha.clamp(0.0, 1.0);
1678 match blend_mode {
1679 BlendMode::Replace => lerp_color(base, effect, alpha),
1680 BlendMode::Additive => blend_additive(with_alpha(effect, alpha), base),
1681 BlendMode::Multiply => blend_multiply(with_alpha(effect, alpha), base),
1682 BlendMode::Screen => blend_screen(with_alpha(effect, alpha), base),
1683 }
1684}
1685
1686#[inline]
1687fn sample_effect(effect: &TableEffect, phase: f32) -> EffectSample {
1688 match *effect {
1689 TableEffect::Pulse {
1690 fg_a,
1691 fg_b,
1692 bg_a,
1693 bg_b,
1694 speed,
1695 phase_offset,
1696 } => {
1697 let t = normalize_phase(phase * speed + phase_offset);
1698 let alpha = pulse_curve(t);
1699 EffectSample {
1700 fg: Some(lerp_color(fg_a, fg_b, alpha)),
1701 bg: Some(lerp_color(bg_a, bg_b, alpha)),
1702 alpha: 1.0,
1703 }
1704 }
1705 TableEffect::BreathingGlow {
1706 fg,
1707 bg,
1708 intensity,
1709 speed,
1710 phase_offset,
1711 asymmetry,
1712 } => {
1713 let t = normalize_phase(phase * speed + phase_offset);
1714 let alpha = (breathing_curve(t, asymmetry) * intensity).clamp(0.0, 1.0);
1715 EffectSample {
1716 fg: Some(fg),
1717 bg: Some(bg),
1718 alpha,
1719 }
1720 }
1721 TableEffect::GradientSweep {
1722 ref gradient,
1723 speed,
1724 phase_offset,
1725 } => {
1726 let t = normalize_phase(phase * speed + phase_offset);
1727 let color = gradient.sample(t);
1728 EffectSample {
1729 fg: Some(color),
1730 bg: Some(color),
1731 alpha: 1.0,
1732 }
1733 }
1734 }
1735}
1736
1737#[inline]
1738fn normalize_phase(phase: f32) -> f32 {
1739 phase.rem_euclid(1.0)
1740}
1741
1742#[inline]
1743fn pulse_curve(t: f32) -> f32 {
1744 0.5 - 0.5 * (std::f32::consts::TAU * t).cos()
1745}
1746
1747#[inline]
1748fn breathing_curve(t: f32, asymmetry: f32) -> f32 {
1749 let t = skew_phase(t, asymmetry);
1750 0.5 - 0.5 * (std::f32::consts::TAU * t).cos()
1751}
1752
1753#[inline]
1754fn skew_phase(t: f32, asymmetry: f32) -> f32 {
1755 let skew = asymmetry.clamp(-0.9, 0.9);
1756 if skew == 0.0 {
1757 return t;
1758 }
1759 if skew > 0.0 {
1760 t.powf(1.0 + skew * 2.0)
1761 } else {
1762 1.0 - (1.0 - t).powf(1.0 - skew * 2.0)
1763 }
1764}
1765
1766#[inline]
1767fn with_alpha(color: PackedRgba, alpha: f32) -> PackedRgba {
1768 let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
1769 PackedRgba::rgba(color.r(), color.g(), color.b(), a)
1770}
1771
1772#[inline]
1773fn blend_additive(top: PackedRgba, bottom: PackedRgba) -> PackedRgba {
1774 let ta = top.a() as f32 / 255.0;
1775 let r = (bottom.r() as f32 + top.r() as f32 * ta).min(255.0) as u8;
1776 let g = (bottom.g() as f32 + top.g() as f32 * ta).min(255.0) as u8;
1777 let b = (bottom.b() as f32 + top.b() as f32 * ta).min(255.0) as u8;
1778 let a = bottom.a().max(top.a());
1779 PackedRgba::rgba(r, g, b, a)
1780}
1781
1782#[inline]
1783fn blend_multiply(top: PackedRgba, bottom: PackedRgba) -> PackedRgba {
1784 let ta = top.a() as f32 / 255.0;
1785 let mr = (top.r() as f32 * bottom.r() as f32 / 255.0) as u8;
1786 let mg = (top.g() as f32 * bottom.g() as f32 / 255.0) as u8;
1787 let mb = (top.b() as f32 * bottom.b() as f32 / 255.0) as u8;
1788 let r = (bottom.r() as f32 * (1.0 - ta) + mr as f32 * ta) as u8;
1789 let g = (bottom.g() as f32 * (1.0 - ta) + mg as f32 * ta) as u8;
1790 let b = (bottom.b() as f32 * (1.0 - ta) + mb as f32 * ta) as u8;
1791 let a = bottom.a().max(top.a());
1792 PackedRgba::rgba(r, g, b, a)
1793}
1794
1795#[inline]
1796fn blend_screen(top: PackedRgba, bottom: PackedRgba) -> PackedRgba {
1797 let ta = top.a() as f32 / 255.0;
1798 let sr = 255 - ((255 - top.r()) as u16 * (255 - bottom.r()) as u16 / 255) as u8;
1799 let sg = 255 - ((255 - top.g()) as u16 * (255 - bottom.g()) as u16 / 255) as u8;
1800 let sb = 255 - ((255 - top.b()) as u16 * (255 - bottom.b()) as u16 / 255) as u8;
1801 let r = (bottom.r() as f32 * (1.0 - ta) + sr as f32 * ta) as u8;
1802 let g = (bottom.g() as f32 * (1.0 - ta) + sg as f32 * ta) as u8;
1803 let b = (bottom.b() as f32 * (1.0 - ta) + sb as f32 * ta) as u8;
1804 let a = bottom.a().max(top.a());
1805 PackedRgba::rgba(r, g, b, a)
1806}
1807
1808impl Default for TableTheme {
1809 fn default() -> Self {
1810 Self::graphite()
1811 }
1812}
1813
1814#[inline]
1815fn classic_color(profile: ColorProfile, rgb: (u8, u8, u8), ansi16: Ansi16) -> PackedRgba {
1816 let color = match profile {
1817 ColorProfile::Ansi16 => Color::Ansi16(ansi16),
1818 _ => Color::rgb(rgb.0, rgb.1, rgb.2).downgrade(profile),
1819 };
1820 let rgb = color.to_rgb();
1821 PackedRgba::rgb(rgb.r, rgb.g, rgb.b)
1822}
1823
1824#[derive(Clone, Copy, Debug)]
1829struct StableHasher {
1830 state: u64,
1831}
1832
1833impl StableHasher {
1834 const OFFSET: u64 = 0xcbf29ce484222325;
1835 const PRIME: u64 = 0x100000001b3;
1836
1837 #[must_use]
1838 const fn new() -> Self {
1839 Self {
1840 state: Self::OFFSET,
1841 }
1842 }
1843}
1844
1845impl Hasher for StableHasher {
1846 fn finish(&self) -> u64 {
1847 self.state
1848 }
1849
1850 fn write(&mut self, bytes: &[u8]) {
1851 let mut hash = self.state;
1852 for byte in bytes {
1853 hash ^= u64::from(*byte);
1854 hash = hash.wrapping_mul(Self::PRIME);
1855 }
1856 self.state = hash;
1857 }
1858}
1859
1860fn hash_u8(value: u8, hasher: &mut StableHasher) {
1861 hasher.write(&[value]);
1862}
1863
1864fn hash_u32(value: u32, hasher: &mut StableHasher) {
1865 hasher.write(&value.to_le_bytes());
1866}
1867
1868fn hash_u64(value: u64, hasher: &mut StableHasher) {
1869 hasher.write(&value.to_le_bytes());
1870}
1871
1872fn hash_usize(value: usize, hasher: &mut StableHasher) {
1873 hash_u64(value as u64, hasher);
1874}
1875
1876fn hash_f32(value: f32, hasher: &mut StableHasher) {
1877 hash_u32(value.to_bits(), hasher);
1878}
1879
1880fn hash_bool(value: bool, hasher: &mut StableHasher) {
1881 hash_u8(value as u8, hasher);
1882}
1883
1884fn hash_style(style: &Style, hasher: &mut StableHasher) {
1885 style.hash(hasher);
1886}
1887
1888fn hash_packed_rgba(color: PackedRgba, hasher: &mut StableHasher) {
1889 hash_u32(color.0, hasher);
1890}
1891
1892fn hash_preset(preset: Option<TablePresetId>, hasher: &mut StableHasher) {
1893 match preset {
1894 None => hash_u8(0, hasher),
1895 Some(id) => {
1896 hash_u8(1, hasher);
1897 hash_table_preset(id, hasher);
1898 }
1899 }
1900}
1901
1902fn hash_table_preset(preset: TablePresetId, hasher: &mut StableHasher) {
1903 let tag = match preset {
1904 TablePresetId::Aurora => 1,
1905 TablePresetId::Graphite => 2,
1906 TablePresetId::Neon => 3,
1907 TablePresetId::Slate => 4,
1908 TablePresetId::Solar => 5,
1909 TablePresetId::Orchard => 6,
1910 TablePresetId::Paper => 7,
1911 TablePresetId::Midnight => 8,
1912 TablePresetId::TerminalClassic => 9,
1913 };
1914 hash_u8(tag, hasher);
1915}
1916
1917fn hash_table_section(section: TableSection, hasher: &mut StableHasher) {
1918 let tag = match section {
1919 TableSection::Header => 1,
1920 TableSection::Body => 2,
1921 TableSection::Footer => 3,
1922 };
1923 hash_u8(tag, hasher);
1924}
1925
1926fn hash_blend_mode(mode: BlendMode, hasher: &mut StableHasher) {
1927 let tag = match mode {
1928 BlendMode::Replace => 1,
1929 BlendMode::Additive => 2,
1930 BlendMode::Multiply => 3,
1931 BlendMode::Screen => 4,
1932 };
1933 hash_u8(tag, hasher);
1934}
1935
1936fn hash_style_mask(mask: StyleMask, hasher: &mut StableHasher) {
1937 hash_bool(mask.fg, hasher);
1938 hash_bool(mask.bg, hasher);
1939 hash_bool(mask.attrs, hasher);
1940}
1941
1942fn hash_effect_target(target: &TableEffectTarget, hasher: &mut StableHasher) {
1943 match *target {
1944 TableEffectTarget::Section(section) => {
1945 hash_u8(1, hasher);
1946 hash_table_section(section, hasher);
1947 }
1948 TableEffectTarget::Row(row) => {
1949 hash_u8(2, hasher);
1950 hash_usize(row, hasher);
1951 }
1952 TableEffectTarget::RowRange { start, end } => {
1953 hash_u8(3, hasher);
1954 hash_usize(start, hasher);
1955 hash_usize(end, hasher);
1956 }
1957 TableEffectTarget::Column(column) => {
1958 hash_u8(4, hasher);
1959 hash_usize(column, hasher);
1960 }
1961 TableEffectTarget::ColumnRange { start, end } => {
1962 hash_u8(5, hasher);
1963 hash_usize(start, hasher);
1964 hash_usize(end, hasher);
1965 }
1966 TableEffectTarget::AllRows => {
1967 hash_u8(6, hasher);
1968 }
1969 TableEffectTarget::AllCells => {
1970 hash_u8(7, hasher);
1971 }
1972 }
1973}
1974
1975fn hash_gradient(gradient: &Gradient, hasher: &mut StableHasher) {
1976 hash_usize(gradient.stops.len(), hasher);
1977 for (pos, color) in &gradient.stops {
1978 hash_f32(*pos, hasher);
1979 hash_packed_rgba(*color, hasher);
1980 }
1981}
1982
1983fn hash_effect(effect: &TableEffect, hasher: &mut StableHasher) {
1984 match *effect {
1985 TableEffect::Pulse {
1986 fg_a,
1987 fg_b,
1988 bg_a,
1989 bg_b,
1990 speed,
1991 phase_offset,
1992 } => {
1993 hash_u8(1, hasher);
1994 hash_packed_rgba(fg_a, hasher);
1995 hash_packed_rgba(fg_b, hasher);
1996 hash_packed_rgba(bg_a, hasher);
1997 hash_packed_rgba(bg_b, hasher);
1998 hash_f32(speed, hasher);
1999 hash_f32(phase_offset, hasher);
2000 }
2001 TableEffect::BreathingGlow {
2002 fg,
2003 bg,
2004 intensity,
2005 speed,
2006 phase_offset,
2007 asymmetry,
2008 } => {
2009 hash_u8(2, hasher);
2010 hash_packed_rgba(fg, hasher);
2011 hash_packed_rgba(bg, hasher);
2012 hash_f32(intensity, hasher);
2013 hash_f32(speed, hasher);
2014 hash_f32(phase_offset, hasher);
2015 hash_f32(asymmetry, hasher);
2016 }
2017 TableEffect::GradientSweep {
2018 ref gradient,
2019 speed,
2020 phase_offset,
2021 } => {
2022 hash_u8(3, hasher);
2023 hash_gradient(gradient, hasher);
2024 hash_f32(speed, hasher);
2025 hash_f32(phase_offset, hasher);
2026 }
2027 }
2028}
2029
2030fn hash_effect_rule(rule: &TableEffectRule, hasher: &mut StableHasher) {
2031 hash_effect_target(&rule.target, hasher);
2032 hash_effect(&rule.effect, hasher);
2033 hash_u8(rule.priority, hasher);
2034 hash_blend_mode(rule.blend_mode, hasher);
2035 hash_style_mask(rule.style_mask, hasher);
2036}
2037
2038#[cfg(test)]
2039mod tests {
2040 use super::*;
2041 use crate::color::{WCAG_AA_LARGE_TEXT, WCAG_AA_NORMAL_TEXT, contrast_ratio_packed};
2042 #[cfg(feature = "serde")]
2043 use serde_json;
2044
2045 fn base_bg(theme: &TableTheme) -> PackedRgba {
2046 theme
2047 .row
2048 .bg
2049 .or(theme.row_alt.bg)
2050 .or(theme.header.bg)
2051 .or(theme.row_selected.bg)
2052 .or(theme.row_hover.bg)
2053 .unwrap_or(PackedRgba::BLACK)
2054 }
2055
2056 fn expect_fg(preset: TablePresetId, label: &str, style: Style) -> PackedRgba {
2057 let fg = style.fg;
2058 assert!(fg.is_some(), "{preset:?} missing fg for {label}");
2059 fg.unwrap()
2060 }
2061
2062 fn expect_bg(preset: TablePresetId, label: &str, style: Style) -> PackedRgba {
2063 let bg = style.bg;
2064 assert!(bg.is_some(), "{preset:?} missing bg for {label}");
2065 bg.unwrap()
2066 }
2067
2068 fn assert_contrast(
2069 preset: TablePresetId,
2070 label: &str,
2071 fg: PackedRgba,
2072 bg: PackedRgba,
2073 minimum: f64,
2074 ) {
2075 let ratio = contrast_ratio_packed(fg, bg);
2076 assert!(
2077 ratio >= minimum,
2078 "{preset:?} {label} contrast {ratio:.2} below {minimum:.2}"
2079 );
2080 }
2081
2082 fn pulse_effect(fg: PackedRgba, bg: PackedRgba) -> TableEffect {
2083 TableEffect::Pulse {
2084 fg_a: fg,
2085 fg_b: fg,
2086 bg_a: bg,
2087 bg_b: bg,
2088 speed: 1.0,
2089 phase_offset: 0.0,
2090 }
2091 }
2092
2093 fn assert_f32_near(label: &str, value: f32, expected: f32) {
2094 let delta = (value - expected).abs();
2095 assert!(delta <= 1e-6, "{label} expected {expected}, got {value}");
2096 }
2097
2098 #[test]
2099 fn style_mask_default_is_fg_bg() {
2100 let mask = StyleMask::default();
2101 assert!(mask.fg);
2102 assert!(mask.bg);
2103 assert!(!mask.attrs);
2104 }
2105
2106 #[test]
2107 fn effect_target_matches_scope_variants() {
2108 let row_scope = TableEffectScope::row(TableSection::Body, 2);
2109 assert!(TableEffectTarget::Section(TableSection::Body).matches_scope(row_scope));
2110 assert!(!TableEffectTarget::Section(TableSection::Header).matches_scope(row_scope));
2111 assert!(TableEffectTarget::Row(2).matches_scope(row_scope));
2112 assert!(!TableEffectTarget::Row(1).matches_scope(row_scope));
2113 assert!(TableEffectTarget::RowRange { start: 1, end: 3 }.matches_scope(row_scope));
2114 assert!(!TableEffectTarget::RowRange { start: 3, end: 5 }.matches_scope(row_scope));
2115 assert!(TableEffectTarget::AllRows.matches_scope(row_scope));
2116 assert!(TableEffectTarget::AllCells.matches_scope(row_scope));
2117 assert!(!TableEffectTarget::Column(0).matches_scope(row_scope));
2118
2119 let col_scope = TableEffectScope::column(TableSection::Header, 1);
2120 assert!(TableEffectTarget::Column(1).matches_scope(col_scope));
2121 assert!(TableEffectTarget::ColumnRange { start: 0, end: 2 }.matches_scope(col_scope));
2122 assert!(!TableEffectTarget::AllRows.matches_scope(col_scope));
2123 assert!(TableEffectTarget::AllCells.matches_scope(col_scope));
2124
2125 let footer_scope = TableEffectScope::row(TableSection::Footer, 0);
2126 assert!(!TableEffectTarget::AllCells.matches_scope(footer_scope));
2127
2128 let header_section = TableEffectScope::section(TableSection::Header);
2129 assert!(!TableEffectTarget::AllCells.matches_scope(header_section));
2130 }
2131
2132 #[test]
2133 fn effect_resolver_returns_base_without_effects() {
2134 let base = Style::new()
2135 .fg(PackedRgba::rgb(12, 34, 56))
2136 .bg(PackedRgba::rgb(7, 8, 9));
2137 let mut theme = TableTheme::aurora();
2138 theme.effects.clear();
2139
2140 let resolver = theme.effect_resolver();
2141 let scope = TableEffectScope::row(TableSection::Body, 0);
2142 let resolved = resolver.resolve(base, scope, 0.25);
2143 assert_eq!(resolved, base);
2144 }
2145
2146 #[test]
2147 fn effect_resolver_all_rows_excludes_header() {
2148 let base = Style::new().fg(PackedRgba::rgb(10, 10, 10));
2149 let mut theme = TableTheme::aurora();
2150 theme.effects = vec![TableEffectRule::new(
2152 TableEffectTarget::AllRows,
2153 pulse_effect(PackedRgba::rgb(200, 0, 0), PackedRgba::rgb(5, 5, 5)),
2154 )];
2155
2156 let resolver = theme.effect_resolver();
2157 let header_scope = TableEffectScope::row(TableSection::Header, 0);
2158 let body_scope = TableEffectScope::row(TableSection::Body, 0);
2159
2160 let header = resolver.resolve(base, header_scope, 0.5);
2161 let body = resolver.resolve(base, body_scope, 0.5);
2162 assert_eq!(header, base);
2163 assert_eq!(body.fg, Some(PackedRgba::rgb(200, 0, 0)));
2164 }
2165
2166 #[test]
2167 fn effect_resolver_all_cells_includes_header_rows() {
2168 let base = Style::new().fg(PackedRgba::rgb(10, 10, 10));
2169 let mut theme = TableTheme::aurora();
2170 theme.effects = vec![TableEffectRule::new(
2172 TableEffectTarget::AllCells,
2173 pulse_effect(PackedRgba::rgb(0, 200, 0), PackedRgba::rgb(5, 5, 5)),
2174 )];
2175
2176 let resolver = theme.effect_resolver();
2177 let header_scope = TableEffectScope::row(TableSection::Header, 0);
2178 let resolved = resolver.resolve(base, header_scope, 0.5);
2179 assert_eq!(resolved.fg, Some(PackedRgba::rgb(0, 200, 0)));
2180 }
2181
2182 #[test]
2183 fn normalize_phase_wraps_and_curves_are_deterministic() {
2184 assert_f32_near("normalize_phase(-0.25)", normalize_phase(-0.25), 0.75);
2185 assert_f32_near("normalize_phase(1.25)", normalize_phase(1.25), 0.25);
2186 assert_f32_near("pulse_curve(0.0)", pulse_curve(0.0), 0.0);
2187 assert_f32_near("pulse_curve(0.5)", pulse_curve(0.5), 1.0);
2188 assert_f32_near(
2189 "breathing_curve matches pulse at zero asymmetry",
2190 breathing_curve(0.25, 0.0),
2191 pulse_curve(0.25),
2192 );
2193 }
2194
2195 #[test]
2196 fn lerp_color_clamps_out_of_range_t() {
2197 let a = PackedRgba::rgb(0, 0, 0);
2198 let b = PackedRgba::rgb(255, 255, 255);
2199 assert_eq!(lerp_color(a, b, -1.0), a);
2200 assert_eq!(lerp_color(a, b, 2.0), b);
2201 }
2202
2203 #[test]
2204 fn effect_resolver_respects_priority_order() {
2205 let base = Style::new()
2206 .fg(PackedRgba::rgb(10, 10, 10))
2207 .bg(PackedRgba::rgb(20, 20, 20));
2208 let mut theme = TableTheme::aurora();
2209 theme.effects = vec![
2210 TableEffectRule::new(
2211 TableEffectTarget::AllRows,
2212 pulse_effect(PackedRgba::rgb(200, 0, 0), PackedRgba::rgb(0, 0, 0)),
2213 )
2214 .priority(0),
2215 TableEffectRule::new(
2216 TableEffectTarget::AllRows,
2217 pulse_effect(PackedRgba::rgb(0, 0, 200), PackedRgba::rgb(0, 0, 80)),
2218 )
2219 .priority(5),
2220 ];
2221
2222 let resolver = theme.effect_resolver();
2223 let scope = TableEffectScope::row(TableSection::Body, 0);
2224 let resolved = resolver.resolve(base, scope, 0.0);
2225 assert_eq!(resolved.fg, Some(PackedRgba::rgb(0, 0, 200)));
2226 assert_eq!(resolved.bg, Some(PackedRgba::rgb(0, 0, 80)));
2227 }
2228
2229 #[test]
2230 fn effect_resolver_applies_same_priority_in_list_order() {
2231 let base = Style::new().fg(PackedRgba::rgb(5, 5, 5));
2232 let mut theme = TableTheme::aurora();
2233 theme.effects = vec![
2234 TableEffectRule::new(
2235 TableEffectTarget::Row(0),
2236 pulse_effect(PackedRgba::rgb(10, 10, 10), PackedRgba::BLACK),
2237 )
2238 .priority(1),
2239 TableEffectRule::new(
2240 TableEffectTarget::Row(0),
2241 pulse_effect(PackedRgba::rgb(40, 40, 40), PackedRgba::BLACK),
2242 )
2243 .priority(1),
2244 ];
2245
2246 let resolver = theme.effect_resolver();
2247 let scope = TableEffectScope::row(TableSection::Body, 0);
2248 let resolved = resolver.resolve(base, scope, 0.0);
2249 assert_eq!(resolved.fg, Some(PackedRgba::rgb(40, 40, 40)));
2250 }
2251
2252 #[test]
2253 fn effect_resolver_respects_style_mask() {
2254 let base = Style::new()
2255 .fg(PackedRgba::rgb(10, 20, 30))
2256 .bg(PackedRgba::rgb(1, 2, 3));
2257 let mut theme = TableTheme::aurora();
2258 theme.effects = vec![
2259 TableEffectRule::new(
2260 TableEffectTarget::Row(0),
2261 pulse_effect(PackedRgba::rgb(200, 100, 0), PackedRgba::rgb(9, 9, 9)),
2262 )
2263 .style_mask(StyleMask::none()),
2264 ];
2265
2266 let resolver = theme.effect_resolver();
2267 let scope = TableEffectScope::row(TableSection::Body, 0);
2268 let resolved = resolver.resolve(base, scope, 0.0);
2269 assert_eq!(resolved, base);
2270
2271 theme.effects = vec![
2272 TableEffectRule::new(
2273 TableEffectTarget::Row(0),
2274 pulse_effect(PackedRgba::rgb(200, 100, 0), PackedRgba::rgb(9, 9, 9)),
2275 )
2276 .style_mask(StyleMask {
2277 fg: true,
2278 bg: false,
2279 attrs: false,
2280 }),
2281 ];
2282 let resolver = theme.effect_resolver();
2283 let resolved = resolver.resolve(base, scope, 0.0);
2284 assert_eq!(resolved.fg, Some(PackedRgba::rgb(200, 100, 0)));
2285 assert_eq!(resolved.bg, base.bg);
2286 }
2287
2288 #[test]
2289 fn effect_resolver_skips_alpha_zero() {
2290 let base = Style::new()
2291 .fg(PackedRgba::rgb(10, 10, 10))
2292 .bg(PackedRgba::rgb(20, 20, 20));
2293 let mut theme = TableTheme::aurora();
2294 theme.effects = vec![TableEffectRule::new(
2295 TableEffectTarget::Row(0),
2296 TableEffect::BreathingGlow {
2297 fg: PackedRgba::rgb(200, 200, 200),
2298 bg: PackedRgba::rgb(10, 10, 10),
2299 intensity: 0.0,
2300 speed: 1.0,
2301 phase_offset: 0.0,
2302 asymmetry: 0.0,
2303 },
2304 )];
2305
2306 let resolver = theme.effect_resolver();
2307 let scope = TableEffectScope::row(TableSection::Body, 0);
2308 let resolved = resolver.resolve(base, scope, 0.5);
2309 assert_eq!(resolved, base);
2310 }
2311
2312 #[test]
2313 fn presets_set_preset_id() {
2314 let theme = TableTheme::aurora();
2315 assert_eq!(theme.preset_id, Some(TablePresetId::Aurora));
2316 }
2317
2318 #[test]
2319 fn terminal_classic_keeps_profile() {
2320 let theme = TableTheme::terminal_classic_for(ColorProfile::Ansi16);
2321 assert_eq!(theme.preset_id, Some(TablePresetId::TerminalClassic));
2322 assert!(theme.column_gap > 0);
2323 }
2324
2325 #[test]
2326 fn style_hash_is_deterministic() {
2327 let theme = TableTheme::aurora();
2328 let h1 = theme.style_hash();
2329 let h2 = theme.style_hash();
2330 assert_eq!(h1, h2, "style_hash should be stable for identical input");
2331 }
2332
2333 #[test]
2334 fn style_hash_changes_with_layout_params() {
2335 let mut theme = TableTheme::aurora();
2336 let base = theme.style_hash();
2337 theme.padding = theme.padding.saturating_add(1);
2338 assert_ne!(
2339 base,
2340 theme.style_hash(),
2341 "padding should influence style hash"
2342 );
2343 }
2344
2345 #[test]
2346 fn effects_hash_changes_with_rules() {
2347 let mut theme = TableTheme::aurora();
2348 let base = theme.effects_hash();
2349 theme.effects.push(TableEffectRule::new(
2350 TableEffectTarget::AllRows,
2351 TableEffect::BreathingGlow {
2352 fg: PackedRgba::rgb(200, 220, 255),
2353 bg: PackedRgba::rgb(30, 40, 60),
2354 intensity: 0.6,
2355 speed: 0.8,
2356 phase_offset: 0.1,
2357 asymmetry: 0.2,
2358 },
2359 ));
2360 assert_ne!(
2361 base,
2362 theme.effects_hash(),
2363 "effects hash should change with rules"
2364 );
2365 }
2366
2367 #[test]
2368 fn presets_meet_wcag_contrast_targets() {
2369 let presets = [
2370 TablePresetId::Aurora,
2371 TablePresetId::Graphite,
2372 TablePresetId::Neon,
2373 TablePresetId::Slate,
2374 TablePresetId::Solar,
2375 TablePresetId::Orchard,
2376 TablePresetId::Paper,
2377 TablePresetId::Midnight,
2378 TablePresetId::TerminalClassic,
2379 ];
2380
2381 for preset in presets {
2382 let theme = match preset {
2383 TablePresetId::TerminalClassic => {
2384 TableTheme::terminal_classic_for(ColorProfile::Ansi16)
2385 }
2386 _ => TableTheme::preset(preset),
2387 };
2388 let base = base_bg(&theme);
2389
2390 let header_fg = expect_fg(preset, "header", theme.header);
2391 let header_bg = expect_bg(preset, "header", theme.header);
2392 assert_contrast(preset, "header", header_fg, header_bg, WCAG_AA_NORMAL_TEXT);
2393
2394 let row_fg = expect_fg(preset, "row", theme.row);
2395 let row_bg = theme.row.bg.unwrap_or(base);
2396 assert_contrast(preset, "row", row_fg, row_bg, WCAG_AA_NORMAL_TEXT);
2397
2398 let row_alt_fg = expect_fg(preset, "row_alt", theme.row_alt);
2399 let row_alt_bg = expect_bg(preset, "row_alt", theme.row_alt);
2400 assert_contrast(
2401 preset,
2402 "row_alt",
2403 row_alt_fg,
2404 row_alt_bg,
2405 WCAG_AA_NORMAL_TEXT,
2406 );
2407
2408 let selected_fg = expect_fg(preset, "row_selected", theme.row_selected);
2409 let selected_bg = expect_bg(preset, "row_selected", theme.row_selected);
2410 assert_contrast(
2411 preset,
2412 "row_selected",
2413 selected_fg,
2414 selected_bg,
2415 WCAG_AA_NORMAL_TEXT,
2416 );
2417
2418 let hover_fg = expect_fg(preset, "row_hover", theme.row_hover);
2419 let hover_bg = expect_bg(preset, "row_hover", theme.row_hover);
2420 let hover_min = if preset == TablePresetId::TerminalClassic {
2421 WCAG_AA_LARGE_TEXT
2423 } else {
2424 WCAG_AA_NORMAL_TEXT
2425 };
2426 assert_contrast(preset, "row_hover", hover_fg, hover_bg, hover_min);
2427
2428 let border_fg = expect_fg(preset, "border", theme.border);
2429 assert_contrast(preset, "border", border_fg, base, WCAG_AA_LARGE_TEXT);
2430
2431 let divider_fg = expect_fg(preset, "divider", theme.divider);
2432 assert_contrast(preset, "divider", divider_fg, base, WCAG_AA_LARGE_TEXT);
2433 }
2434 }
2435
2436 fn base_spec() -> TableThemeSpec {
2437 TableThemeSpec::from_theme(&TableTheme::aurora())
2438 }
2439
2440 fn sample_rule() -> TableEffectRuleSpec {
2441 TableEffectRuleSpec {
2442 target: TableEffectTarget::AllRows,
2443 effect: TableEffectSpec::Pulse {
2444 fg_a: RgbaSpec::new(10, 20, 30, 255),
2445 fg_b: RgbaSpec::new(40, 50, 60, 255),
2446 bg_a: RgbaSpec::new(5, 5, 5, 255),
2447 bg_b: RgbaSpec::new(9, 9, 9, 255),
2448 speed: 1.0,
2449 phase_offset: 0.0,
2450 },
2451 priority: 0,
2452 blend_mode: BlendMode::Replace,
2453 style_mask: StyleMask::fg_bg(),
2454 }
2455 }
2456
2457 #[test]
2458 fn table_theme_spec_validate_accepts_defaults() {
2459 let spec = base_spec();
2460 assert!(spec.validate().is_ok());
2461 }
2462
2463 #[test]
2464 fn table_theme_spec_validate_rejects_padding_overflow() {
2465 let mut spec = base_spec();
2466 spec.padding = TABLE_THEME_SPEC_MAX_PADDING.saturating_add(1);
2467 let err = spec.validate().expect_err("expected padding range error");
2468 assert_eq!(err.field, "padding");
2469 }
2470
2471 #[test]
2472 fn table_theme_spec_validate_rejects_name_length_overflow() {
2473 let mut spec = base_spec();
2474 spec.name = Some("x".repeat(TABLE_THEME_SPEC_MAX_NAME_LEN.saturating_add(1)));
2475 let err = spec.validate().expect_err("expected name length error");
2476 assert_eq!(err.field, "name");
2477 }
2478
2479 #[test]
2480 fn table_theme_spec_validate_rejects_effect_count_overflow() {
2481 let mut spec = base_spec();
2482 spec.effects = vec![sample_rule(); TABLE_THEME_SPEC_MAX_EFFECTS.saturating_add(1)];
2483 let err = spec.validate().expect_err("expected effects length error");
2484 assert_eq!(err.field, "effects");
2485 }
2486
2487 #[test]
2488 fn table_theme_spec_validate_rejects_style_attr_overflow() {
2489 let mut spec = base_spec();
2490 spec.styles.header.attrs =
2491 vec![StyleAttr::Bold; TABLE_THEME_SPEC_MAX_STYLE_ATTRS.saturating_add(1)];
2492 let err = spec
2493 .validate()
2494 .expect_err("expected style attr length error");
2495 assert_eq!(err.field, "styles.header.attrs");
2496 }
2497
2498 #[test]
2499 fn table_theme_spec_validate_rejects_gradient_stop_count_out_of_range() {
2500 let mut spec = base_spec();
2501 spec.effects = vec![TableEffectRuleSpec {
2502 target: TableEffectTarget::AllRows,
2503 effect: TableEffectSpec::GradientSweep {
2504 gradient: GradientSpec { stops: Vec::new() },
2505 speed: 1.0,
2506 phase_offset: 0.0,
2507 },
2508 priority: 0,
2509 blend_mode: BlendMode::Replace,
2510 style_mask: StyleMask::fg_bg(),
2511 }];
2512 let err = spec
2513 .validate()
2514 .expect_err("expected gradient stop count error");
2515 assert!(
2516 err.field.contains("gradient.stops"),
2517 "unexpected field: {}",
2518 err.field
2519 );
2520 }
2521
2522 #[test]
2523 fn table_theme_spec_validate_rejects_gradient_stop_out_of_range() {
2524 let mut spec = base_spec();
2525 spec.effects = vec![TableEffectRuleSpec {
2526 target: TableEffectTarget::AllRows,
2527 effect: TableEffectSpec::GradientSweep {
2528 gradient: GradientSpec {
2529 stops: vec![GradientStopSpec {
2530 pos: 1.5,
2531 color: RgbaSpec::new(0, 0, 0, 255),
2532 }],
2533 },
2534 speed: 1.0,
2535 phase_offset: 0.0,
2536 },
2537 priority: 0,
2538 blend_mode: BlendMode::Replace,
2539 style_mask: StyleMask::fg_bg(),
2540 }];
2541 let err = spec
2542 .validate()
2543 .expect_err("expected gradient stop range error");
2544 assert!(
2545 err.field.contains("gradient.stops"),
2546 "unexpected field: {}",
2547 err.field
2548 );
2549 }
2550
2551 #[test]
2552 fn table_theme_spec_validate_rejects_inverted_row_range() {
2553 let mut spec = base_spec();
2554 let mut rule = sample_rule();
2555 rule.target = TableEffectTarget::RowRange { start: 3, end: 1 };
2556 spec.effects = vec![rule];
2557 let err = spec.validate().expect_err("expected target range error");
2558 assert!(
2559 err.field.contains("target"),
2560 "unexpected field: {}",
2561 err.field
2562 );
2563 }
2564
2565 #[cfg(feature = "serde")]
2566 #[test]
2567 fn table_theme_spec_json_rejects_unknown_field() {
2568 let mut value = serde_json::to_value(base_spec()).expect("TableThemeSpec should serialize");
2569 let obj = value.as_object_mut().expect("spec should be an object");
2570 obj.insert("unknown_field".to_string(), serde_json::json!(true));
2571 let err = serde_json::from_value::<TableThemeSpec>(value)
2572 .expect_err("expected unknown field error");
2573 assert!(
2574 err.to_string().contains("unknown field"),
2575 "unexpected error: {err}"
2576 );
2577 }
2578
2579 #[cfg(feature = "serde")]
2580 #[test]
2581 fn table_theme_spec_json_has_canonical_key_order() {
2582 let json =
2583 serde_json::to_string_pretty(&base_spec()).expect("TableThemeSpec should serialize");
2584 let keys = [
2585 "\"version\"",
2586 "\"name\"",
2587 "\"preset_id\"",
2588 "\"padding\"",
2589 "\"column_gap\"",
2590 "\"row_height\"",
2591 "\"styles\"",
2592 "\"effects\"",
2593 ];
2594 let mut last = 0usize;
2595 for key in keys {
2596 let pos = json.find(key);
2597 assert!(pos.is_some(), "missing key {key}");
2598 let pos = pos.unwrap();
2599 assert!(
2600 pos >= last,
2601 "key {key} is out of order (pos {pos} < {last})"
2602 );
2603 last = pos;
2604 }
2605 }
2606
2607 #[test]
2612 fn gradient_empty_returns_transparent() {
2613 let g = Gradient::new(vec![]);
2614 let c = g.sample(0.5);
2615 assert_eq!(c, PackedRgba::TRANSPARENT);
2616 }
2617
2618 #[test]
2619 fn gradient_single_stop_returns_that_color() {
2620 let red = PackedRgba::rgb(255, 0, 0);
2621 let g = Gradient::new(vec![(0.5, red)]);
2622 assert_eq!(g.sample(0.0), red);
2623 assert_eq!(g.sample(0.5), red);
2624 assert_eq!(g.sample(1.0), red);
2625 }
2626
2627 #[test]
2628 fn gradient_two_stops_interpolates() {
2629 let black = PackedRgba::rgb(0, 0, 0);
2630 let white = PackedRgba::rgb(255, 255, 255);
2631 let g = Gradient::new(vec![(0.0, black), (1.0, white)]);
2632 let mid = g.sample(0.5);
2633 assert!(mid.r() > 120 && mid.r() < 135, "mid.r() = {}", mid.r());
2635 }
2636
2637 #[test]
2638 fn gradient_sorts_stops() {
2639 let a = PackedRgba::rgb(255, 0, 0);
2640 let b = PackedRgba::rgb(0, 255, 0);
2641 let g = Gradient::new(vec![(0.8, b), (0.2, a)]);
2642 let stops = g.stops();
2643 assert!(stops[0].0 < stops[1].0, "stops should be sorted");
2644 }
2645
2646 #[test]
2647 fn gradient_clamps_t() {
2648 let red = PackedRgba::rgb(255, 0, 0);
2649 let blue = PackedRgba::rgb(0, 0, 255);
2650 let g = Gradient::new(vec![(0.0, red), (1.0, blue)]);
2651 assert_eq!(g.sample(-1.0), red);
2652 assert_eq!(g.sample(2.0), blue);
2653 }
2654
2655 #[test]
2660 fn lerp_u8_basic() {
2661 assert_eq!(lerp_u8(0, 100, 0.0), 0);
2662 assert_eq!(lerp_u8(0, 100, 1.0), 100);
2663 assert_eq!(lerp_u8(0, 100, 0.5), 50);
2664 }
2665
2666 #[test]
2667 fn lerp_u8_clamps() {
2668 assert_eq!(lerp_u8(100, 200, -1.0), 0);
2670 assert_eq!(lerp_u8(0, 100, 2.0), 200);
2671 }
2672
2673 #[test]
2678 fn style_mask_all() {
2679 let mask = StyleMask::all();
2680 assert!(mask.fg);
2681 assert!(mask.bg);
2682 assert!(mask.attrs);
2683 }
2684
2685 #[test]
2686 fn style_mask_none() {
2687 let mask = StyleMask::none();
2688 assert!(!mask.fg);
2689 assert!(!mask.bg);
2690 assert!(!mask.attrs);
2691 }
2692
2693 #[test]
2694 fn style_mask_fg_bg_no_attrs() {
2695 let mask = StyleMask::fg_bg();
2696 assert!(mask.fg);
2697 assert!(mask.bg);
2698 assert!(!mask.attrs);
2699 }
2700
2701 #[test]
2706 fn effect_rule_defaults() {
2707 let rule = TableEffectRule::new(
2708 TableEffectTarget::AllRows,
2709 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2710 );
2711 assert_eq!(rule.priority, 0);
2712 assert_eq!(rule.blend_mode, BlendMode::Replace);
2713 assert_eq!(rule.style_mask, StyleMask::fg_bg());
2714 }
2715
2716 #[test]
2717 fn effect_rule_builder_chain() {
2718 let rule = TableEffectRule::new(
2719 TableEffectTarget::AllRows,
2720 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2721 )
2722 .priority(5)
2723 .blend_mode(BlendMode::Additive)
2724 .style_mask(StyleMask::all());
2725
2726 assert_eq!(rule.priority, 5);
2727 assert_eq!(rule.blend_mode, BlendMode::Additive);
2728 assert_eq!(rule.style_mask, StyleMask::all());
2729 }
2730
2731 #[test]
2736 fn default_theme_is_graphite() {
2737 let theme = TableTheme::default();
2738 assert_eq!(theme.preset_id, Some(TablePresetId::Graphite));
2739 }
2740
2741 #[test]
2742 fn preset_factory_matches_named() {
2743 let from_preset = TableTheme::preset(TablePresetId::Aurora);
2744 let from_named = TableTheme::aurora();
2745 assert_eq!(from_preset.style_hash(), from_named.style_hash());
2746 }
2747
2748 #[test]
2749 fn with_padding_sets_value() {
2750 let theme = TableTheme::graphite().with_padding(3);
2751 assert_eq!(theme.padding, 3);
2752 }
2753
2754 #[test]
2755 fn with_column_gap_sets_value() {
2756 let theme = TableTheme::graphite().with_column_gap(5);
2757 assert_eq!(theme.column_gap, 5);
2758 }
2759
2760 #[test]
2761 fn with_row_height_sets_value() {
2762 let theme = TableTheme::graphite().with_row_height(2);
2763 assert_eq!(theme.row_height, 2);
2764 }
2765
2766 #[test]
2767 fn with_effect_appends() {
2768 let theme = TableTheme::graphite().with_effect(TableEffectRule::new(
2769 TableEffectTarget::AllRows,
2770 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2771 ));
2772 assert_eq!(theme.effects.len(), 1);
2773 }
2774
2775 #[test]
2776 fn clear_effects_removes_all() {
2777 let theme = TableTheme::graphite()
2778 .with_effect(TableEffectRule::new(
2779 TableEffectTarget::AllRows,
2780 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2781 ))
2782 .clear_effects();
2783 assert!(theme.effects.is_empty());
2784 }
2785
2786 #[test]
2787 fn with_preset_id_overrides() {
2788 let theme = TableTheme::graphite().with_preset_id(Some(TablePresetId::Neon));
2789 assert_eq!(theme.preset_id, Some(TablePresetId::Neon));
2790 }
2791
2792 #[test]
2797 fn diagnostics_captures_theme_state() {
2798 let theme = TableTheme::aurora()
2799 .with_padding(4)
2800 .with_effect(TableEffectRule::new(
2801 TableEffectTarget::AllRows,
2802 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
2803 ));
2804 let diag = theme.diagnostics();
2805 assert_eq!(diag.preset_id, Some(TablePresetId::Aurora));
2806 assert_eq!(diag.padding, 4);
2807 assert_eq!(diag.effect_count, 1);
2808 assert_ne!(diag.style_hash, 0);
2809 }
2810
2811 #[test]
2816 fn scope_section_has_no_row_or_column() {
2817 let s = TableEffectScope::section(TableSection::Header);
2818 assert_eq!(s.section, TableSection::Header);
2819 assert_eq!(s.row, None);
2820 assert_eq!(s.column, None);
2821 }
2822
2823 #[test]
2824 fn scope_row_has_row_no_column() {
2825 let s = TableEffectScope::row(TableSection::Body, 3);
2826 assert_eq!(s.row, Some(3));
2827 assert_eq!(s.column, None);
2828 }
2829
2830 #[test]
2831 fn scope_column_has_column_no_row() {
2832 let s = TableEffectScope::column(TableSection::Footer, 7);
2833 assert_eq!(s.column, Some(7));
2834 assert_eq!(s.row, None);
2835 }
2836
2837 #[test]
2842 fn pulse_curve_boundaries() {
2843 assert_f32_near("pulse(0.0)", pulse_curve(0.0), 0.0);
2844 assert_f32_near("pulse(0.5)", pulse_curve(0.5), 1.0);
2845 assert_f32_near("pulse(1.0)", pulse_curve(1.0), 0.0);
2846 }
2847
2848 #[test]
2849 fn breathing_curve_zero_asymmetry_matches_pulse() {
2850 for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
2851 assert_f32_near(
2852 &format!("breathing({t})"),
2853 breathing_curve(t, 0.0),
2854 pulse_curve(t),
2855 );
2856 }
2857 }
2858
2859 #[test]
2860 fn skew_phase_zero_is_identity() {
2861 for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
2862 assert_f32_near(&format!("skew({t})"), skew_phase(t, 0.0), t);
2863 }
2864 }
2865
2866 #[test]
2867 fn skew_phase_positive_slows_start() {
2868 let skewed = skew_phase(0.5, 0.5);
2870 assert!(
2871 skewed < 0.5,
2872 "positive skew should compress early phase: {skewed}"
2873 );
2874 }
2875
2876 #[test]
2877 fn skew_phase_negative_accelerates_start() {
2878 let skewed = skew_phase(0.5, -0.5);
2879 assert!(
2880 skewed > 0.5,
2881 "negative skew should expand early phase: {skewed}"
2882 );
2883 }
2884
2885 #[test]
2890 fn effect_resolver_additive_blend() {
2891 let base_fg = PackedRgba::rgb(100, 50, 50);
2892 let effect_fg = PackedRgba::rgb(50, 50, 50);
2893 let theme = TableTheme::graphite().with_effect(
2894 TableEffectRule::new(
2895 TableEffectTarget::AllRows,
2896 TableEffect::Pulse {
2897 fg_a: effect_fg,
2898 fg_b: effect_fg,
2899 bg_a: PackedRgba::BLACK,
2900 bg_b: PackedRgba::BLACK,
2901 speed: 1.0,
2902 phase_offset: 0.0,
2903 },
2904 )
2905 .blend_mode(BlendMode::Additive),
2906 );
2907 let resolver = theme.effect_resolver();
2908 let base = Style::new().fg(base_fg).bg(PackedRgba::BLACK);
2909 let resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.25);
2910 let resolved_fg = resolved.fg.unwrap();
2912 assert!(
2913 resolved_fg.r() >= base_fg.r(),
2914 "additive should brighten red"
2915 );
2916 }
2917
2918 #[test]
2919 fn effect_resolver_multiply_blend() {
2920 let base_fg = PackedRgba::rgb(200, 200, 200);
2921 let effect_fg = PackedRgba::rgb(128, 128, 128);
2922 let theme = TableTheme::graphite().with_effect(
2923 TableEffectRule::new(
2924 TableEffectTarget::AllRows,
2925 TableEffect::Pulse {
2926 fg_a: effect_fg,
2927 fg_b: effect_fg,
2928 bg_a: PackedRgba::BLACK,
2929 bg_b: PackedRgba::BLACK,
2930 speed: 1.0,
2931 phase_offset: 0.0,
2932 },
2933 )
2934 .blend_mode(BlendMode::Multiply),
2935 );
2936 let resolver = theme.effect_resolver();
2937 let base = Style::new().fg(base_fg).bg(PackedRgba::BLACK);
2938 let resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.25);
2939 let resolved_fg = resolved.fg.unwrap();
2940 assert!(resolved_fg.r() <= base_fg.r(), "multiply should darken");
2942 }
2943
2944 #[test]
2949 fn gradient_coincident_stops_returns_first() {
2950 let a = PackedRgba::rgb(255, 0, 0);
2951 let b = PackedRgba::rgb(0, 0, 255);
2952 let g = Gradient::new(vec![(0.5, a), (0.5, b)]);
2953 let c = g.sample(0.5);
2955 assert_eq!(c, a);
2956 let c_before = g.sample(0.3);
2958 assert_eq!(c_before, a);
2959 }
2960
2961 #[test]
2962 fn gradient_three_stops_middle_interpolation() {
2963 let r = PackedRgba::rgb(255, 0, 0);
2964 let g_color = PackedRgba::rgb(0, 255, 0);
2965 let b = PackedRgba::rgb(0, 0, 255);
2966 let g = Gradient::new(vec![(0.0, r), (0.5, g_color), (1.0, b)]);
2967 let c = g.sample(0.25);
2969 assert!(c.r() > 100, "should have red component: {}", c.r());
2970 assert!(c.g() > 100, "should have green component: {}", c.g());
2971 assert!(c.b() < 10, "should have minimal blue: {}", c.b());
2972 }
2973
2974 #[test]
2975 fn gradient_partial_eq() {
2976 let a = Gradient::new(vec![(0.0, PackedRgba::RED), (1.0, PackedRgba::BLACK)]);
2977 let b = Gradient::new(vec![(0.0, PackedRgba::RED), (1.0, PackedRgba::BLACK)]);
2978 assert_eq!(a, b);
2979 }
2980
2981 #[test]
2982 fn lerp_u8_same_values() {
2983 assert_eq!(lerp_u8(128, 128, 0.5), 128);
2984 }
2985
2986 #[test]
2987 fn lerp_u8_max_to_max() {
2988 assert_eq!(lerp_u8(255, 255, 0.5), 255);
2989 }
2990
2991 #[test]
2992 fn lerp_color_exact_midpoint() {
2993 let a = PackedRgba::rgba(0, 0, 0, 0);
2994 let b = PackedRgba::rgba(200, 100, 50, 200);
2995 let mid = lerp_color(a, b, 0.5);
2996 assert_eq!(mid.r(), 100);
2997 assert_eq!(mid.g(), 50);
2998 assert_eq!(mid.b(), 25);
2999 assert_eq!(mid.a(), 100);
3000 }
3001
3002 #[test]
3003 fn blend_mode_default_is_replace() {
3004 assert_eq!(BlendMode::default(), BlendMode::Replace);
3005 }
3006
3007 #[test]
3008 fn blend_mode_traits() {
3009 let mode = BlendMode::Screen;
3010 let debug = format!("{:?}", mode);
3011 assert!(debug.contains("Screen"));
3012 let cloned = mode;
3013 assert_eq!(mode, cloned);
3014 assert_ne!(BlendMode::Additive, BlendMode::Multiply);
3015 }
3016
3017 #[test]
3018 fn effect_resolver_screen_blend() {
3019 let base_fg = PackedRgba::rgb(100, 100, 100);
3020 let effect_fg = PackedRgba::rgb(128, 128, 128);
3021 let theme = TableTheme::graphite().with_effect(
3022 TableEffectRule::new(
3023 TableEffectTarget::AllRows,
3024 TableEffect::Pulse {
3025 fg_a: effect_fg,
3026 fg_b: effect_fg,
3027 bg_a: PackedRgba::BLACK,
3028 bg_b: PackedRgba::BLACK,
3029 speed: 1.0,
3030 phase_offset: 0.0,
3031 },
3032 )
3033 .blend_mode(BlendMode::Screen),
3034 );
3035 let resolver = theme.effect_resolver();
3036 let base = Style::new().fg(base_fg).bg(PackedRgba::BLACK);
3037 let resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.25);
3038 let resolved_fg = resolved.fg.unwrap();
3039 assert!(
3041 resolved_fg.r() >= base_fg.r(),
3042 "screen should lighten: {} vs {}",
3043 resolved_fg.r(),
3044 base_fg.r()
3045 );
3046 }
3047
3048 #[test]
3049 fn effect_resolver_gradient_sweep() {
3050 let gradient = Gradient::new(vec![
3051 (0.0, PackedRgba::rgb(255, 0, 0)),
3052 (1.0, PackedRgba::rgb(0, 0, 255)),
3053 ]);
3054 let theme = TableTheme::graphite().with_effect(TableEffectRule::new(
3055 TableEffectTarget::AllRows,
3056 TableEffect::GradientSweep {
3057 gradient,
3058 speed: 1.0,
3059 phase_offset: 0.0,
3060 },
3061 ));
3062 let resolver = theme.effect_resolver();
3063 let base = Style::new().fg(PackedRgba::BLACK);
3064 let resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.0);
3065 let fg = resolved.fg.unwrap();
3067 assert_eq!(fg.r(), 255);
3068 assert_eq!(fg.b(), 0);
3069 }
3070
3071 #[test]
3072 fn effect_resolver_breathing_glow_with_asymmetry() {
3073 let theme = TableTheme::graphite().with_effect(TableEffectRule::new(
3074 TableEffectTarget::AllRows,
3075 TableEffect::BreathingGlow {
3076 fg: PackedRgba::rgb(255, 255, 255),
3077 bg: PackedRgba::rgb(50, 50, 50),
3078 intensity: 1.0,
3079 speed: 1.0,
3080 phase_offset: 0.0,
3081 asymmetry: 0.5,
3082 },
3083 ));
3084 let resolver = theme.effect_resolver();
3085 let base = Style::new()
3086 .fg(PackedRgba::rgb(100, 100, 100))
3087 .bg(PackedRgba::rgb(20, 20, 20));
3088 let _resolved = resolver.resolve(base, TableEffectScope::row(TableSection::Body, 0), 0.25);
3090 }
3091
3092 #[test]
3093 fn apply_channel_none_base_uses_effect() {
3094 let result = apply_channel(
3095 None,
3096 Some(PackedRgba::rgb(100, 200, 50)),
3097 1.0,
3098 BlendMode::Replace,
3099 );
3100 assert!(result.is_some());
3101 let c = result.unwrap();
3102 assert_eq!(c.r(), 100);
3103 assert_eq!(c.g(), 200);
3104 assert_eq!(c.b(), 50);
3105 }
3106
3107 #[test]
3108 fn apply_channel_none_effect_returns_none() {
3109 let result = apply_channel(Some(PackedRgba::RED), None, 1.0, BlendMode::Replace);
3110 assert!(result.is_none());
3111 }
3112
3113 #[test]
3114 fn spec_round_trip_preserves_theme() {
3115 let theme = TableTheme::aurora()
3116 .with_padding(3)
3117 .with_column_gap(2)
3118 .with_row_height(2)
3119 .with_preset_id(Some(TablePresetId::Aurora));
3120 let spec = TableThemeSpec::from_theme(&theme);
3121 let restored = spec.into_theme();
3122 assert_eq!(restored.padding, 3);
3123 assert_eq!(restored.column_gap, 2);
3124 assert_eq!(restored.row_height, 2);
3125 assert_eq!(restored.preset_id, Some(TablePresetId::Aurora));
3126 assert_eq!(restored.style_hash(), theme.style_hash());
3127 }
3128
3129 #[test]
3130 fn spec_round_trip_with_effects() {
3131 let theme = TableTheme::neon().with_effect(TableEffectRule::new(
3132 TableEffectTarget::Row(5),
3133 TableEffect::Pulse {
3134 fg_a: PackedRgba::rgb(10, 20, 30),
3135 fg_b: PackedRgba::rgb(40, 50, 60),
3136 bg_a: PackedRgba::rgb(1, 2, 3),
3137 bg_b: PackedRgba::rgb(4, 5, 6),
3138 speed: 2.0,
3139 phase_offset: 0.3,
3140 },
3141 ));
3142 let spec = TableThemeSpec::from_theme(&theme);
3143 let restored = spec.into_theme();
3144 assert_eq!(restored.effects.len(), 1);
3145 assert_eq!(restored.effects_hash(), theme.effects_hash());
3146 }
3147
3148 #[test]
3149 fn spec_validate_rejects_bad_version() {
3150 let mut spec = base_spec();
3151 spec.version = 99;
3152 let err = spec.validate().expect_err("expected version error");
3153 assert_eq!(err.field, "version");
3154 }
3155
3156 #[test]
3157 fn spec_validate_rejects_row_height_zero() {
3158 let mut spec = base_spec();
3159 spec.row_height = 0;
3160 let err = spec.validate().expect_err("expected row_height error");
3161 assert_eq!(err.field, "row_height");
3162 }
3163
3164 #[test]
3165 fn spec_validate_rejects_column_gap_overflow() {
3166 let mut spec = base_spec();
3167 spec.column_gap = TABLE_THEME_SPEC_MAX_COLUMN_GAP + 1;
3168 let err = spec.validate().expect_err("expected column_gap error");
3169 assert_eq!(err.field, "column_gap");
3170 }
3171
3172 #[test]
3173 fn spec_validate_rejects_inverted_column_range() {
3174 let mut spec = base_spec();
3175 let mut rule = sample_rule();
3176 rule.target = TableEffectTarget::ColumnRange { start: 5, end: 2 };
3177 spec.effects = vec![rule];
3178 let err = spec.validate().expect_err("expected column range error");
3179 assert!(err.field.contains("target"), "field: {}", err.field);
3180 }
3181
3182 #[test]
3183 fn spec_validate_rejects_nan_speed() {
3184 let mut spec = base_spec();
3185 spec.effects = vec![TableEffectRuleSpec {
3186 target: TableEffectTarget::AllRows,
3187 effect: TableEffectSpec::Pulse {
3188 fg_a: RgbaSpec::new(0, 0, 0, 255),
3189 fg_b: RgbaSpec::new(0, 0, 0, 255),
3190 bg_a: RgbaSpec::new(0, 0, 0, 255),
3191 bg_b: RgbaSpec::new(0, 0, 0, 255),
3192 speed: f32::NAN,
3193 phase_offset: 0.0,
3194 },
3195 priority: 0,
3196 blend_mode: BlendMode::Replace,
3197 style_mask: StyleMask::fg_bg(),
3198 }];
3199 let err = spec.validate().expect_err("expected NaN error");
3200 assert!(err.message.contains("finite"), "error msg: {}", err.message);
3201 }
3202
3203 #[test]
3204 fn spec_validate_rejects_inf_intensity() {
3205 let mut spec = base_spec();
3206 spec.effects = vec![TableEffectRuleSpec {
3207 target: TableEffectTarget::AllRows,
3208 effect: TableEffectSpec::BreathingGlow {
3209 fg: RgbaSpec::new(0, 0, 0, 255),
3210 bg: RgbaSpec::new(0, 0, 0, 255),
3211 intensity: f32::INFINITY,
3212 speed: 1.0,
3213 phase_offset: 0.0,
3214 asymmetry: 0.0,
3215 },
3216 priority: 0,
3217 blend_mode: BlendMode::Replace,
3218 style_mask: StyleMask::fg_bg(),
3219 }];
3220 let err = spec.validate().expect_err("expected Inf error");
3221 assert!(err.message.contains("finite"), "error msg: {}", err.message);
3222 }
3223
3224 #[test]
3225 fn style_spec_round_trip() {
3226 let style = Style::new()
3227 .fg(PackedRgba::rgb(100, 150, 200))
3228 .bg(PackedRgba::rgb(10, 20, 30))
3229 .bold()
3230 .italic();
3231 let spec = StyleSpec::from_style(&style);
3232 let restored = spec.to_style();
3233 assert_eq!(restored.fg, style.fg);
3234 assert_eq!(restored.bg, style.bg);
3235 assert!(restored.has_attr(StyleFlags::BOLD));
3236 assert!(restored.has_attr(StyleFlags::ITALIC));
3237 }
3238
3239 #[test]
3240 fn gradient_spec_round_trip() {
3241 let gradient = Gradient::new(vec![
3242 (0.0, PackedRgba::rgb(255, 0, 0)),
3243 (0.5, PackedRgba::rgb(0, 255, 0)),
3244 (1.0, PackedRgba::rgb(0, 0, 255)),
3245 ]);
3246 let spec = GradientSpec::from_gradient(&gradient);
3247 let restored = spec.to_gradient();
3248 assert_eq!(restored.stops().len(), 3);
3249 assert_eq!(restored.sample(0.0), gradient.sample(0.0));
3250 assert_eq!(restored.sample(1.0), gradient.sample(1.0));
3251 }
3252
3253 #[test]
3254 fn effect_spec_round_trip_pulse() {
3255 let effect = TableEffect::Pulse {
3256 fg_a: PackedRgba::rgb(10, 20, 30),
3257 fg_b: PackedRgba::rgb(40, 50, 60),
3258 bg_a: PackedRgba::rgb(1, 2, 3),
3259 bg_b: PackedRgba::rgb(4, 5, 6),
3260 speed: 1.5,
3261 phase_offset: 0.2,
3262 };
3263 let spec = TableEffectSpec::from_effect(&effect);
3264 let _restored = spec.to_effect(); }
3266
3267 #[test]
3268 fn effect_spec_round_trip_breathing() {
3269 let effect = TableEffect::BreathingGlow {
3270 fg: PackedRgba::rgb(200, 200, 200),
3271 bg: PackedRgba::rgb(10, 10, 10),
3272 intensity: 0.7,
3273 speed: 2.0,
3274 phase_offset: 0.5,
3275 asymmetry: -0.3,
3276 };
3277 let spec = TableEffectSpec::from_effect(&effect);
3278 let _restored = spec.to_effect();
3279 }
3280
3281 #[test]
3282 fn effect_spec_round_trip_gradient_sweep() {
3283 let effect = TableEffect::GradientSweep {
3284 gradient: Gradient::new(vec![(0.0, PackedRgba::RED), (1.0, PackedRgba::BLACK)]),
3285 speed: 1.0,
3286 phase_offset: 0.0,
3287 };
3288 let spec = TableEffectSpec::from_effect(&effect);
3289 let _restored = spec.to_effect();
3290 }
3291
3292 #[test]
3293 fn effect_rule_spec_round_trip() {
3294 let rule = TableEffectRule::new(
3295 TableEffectTarget::RowRange { start: 1, end: 5 },
3296 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
3297 )
3298 .priority(3)
3299 .blend_mode(BlendMode::Screen)
3300 .style_mask(StyleMask::all());
3301 let spec = TableEffectRuleSpec::from_rule(&rule);
3302 let restored = spec.to_rule();
3303 assert_eq!(restored.priority, 3);
3304 assert_eq!(restored.blend_mode, BlendMode::Screen);
3305 assert_eq!(restored.style_mask, StyleMask::all());
3306 }
3307
3308 #[test]
3309 fn attrs_flags_round_trip_all() {
3310 let flags = StyleFlags::BOLD
3311 | StyleFlags::DIM
3312 | StyleFlags::ITALIC
3313 | StyleFlags::UNDERLINE
3314 | StyleFlags::BLINK
3315 | StyleFlags::REVERSE
3316 | StyleFlags::HIDDEN
3317 | StyleFlags::STRIKETHROUGH
3318 | StyleFlags::DOUBLE_UNDERLINE
3319 | StyleFlags::CURLY_UNDERLINE;
3320 let attrs = attrs_from_flags(flags);
3321 assert_eq!(attrs.len(), 10);
3322 let restored = flags_from_attrs(&attrs);
3323 assert_eq!(restored, Some(flags));
3324 }
3325
3326 #[test]
3327 fn flags_from_empty_attrs_returns_none() {
3328 let result = flags_from_attrs(&[]);
3329 assert!(result.is_none());
3330 }
3331
3332 #[test]
3333 fn table_theme_spec_error_display() {
3334 let err = TableThemeSpecError::new("test_field", "something went wrong");
3335 let display = format!("{}", err);
3336 assert_eq!(display, "test_field: something went wrong");
3337 let debug = format!("{:?}", err);
3338 assert!(debug.contains("TableThemeSpecError"));
3339 }
3340
3341 #[test]
3342 fn table_theme_spec_error_is_std_error() {
3343 let err = TableThemeSpecError::new("f", "m");
3344 let _: &dyn std::error::Error = &err;
3345 }
3346
3347 #[test]
3348 fn table_theme_diagnostics_clone_and_debug() {
3349 let theme = TableTheme::aurora();
3350 let diag = theme.diagnostics();
3351 let cloned = diag.clone();
3352 assert_eq!(cloned.preset_id, diag.preset_id);
3353 let debug = format!("{:?}", diag);
3354 assert!(debug.contains("TableThemeDiagnostics"));
3355 }
3356
3357 #[test]
3358 fn rgba_spec_round_trip() {
3359 let packed = PackedRgba::rgba(10, 20, 30, 40);
3360 let spec = RgbaSpec::from(packed);
3361 assert_eq!(spec.r, 10);
3362 assert_eq!(spec.g, 20);
3363 assert_eq!(spec.b, 30);
3364 assert_eq!(spec.a, 40);
3365 let restored = PackedRgba::from(spec);
3366 assert_eq!(restored, packed);
3367 }
3368
3369 #[test]
3370 fn with_builders_all_styles() {
3371 let s = Style::new().fg(PackedRgba::RED);
3372 let theme = TableTheme::graphite()
3373 .with_border(s)
3374 .with_header(s)
3375 .with_row(s)
3376 .with_row_alt(s)
3377 .with_row_selected(s)
3378 .with_row_hover(s)
3379 .with_divider(s);
3380 assert_eq!(theme.border.fg, Some(PackedRgba::RED));
3381 assert_eq!(theme.header.fg, Some(PackedRgba::RED));
3382 assert_eq!(theme.row.fg, Some(PackedRgba::RED));
3383 assert_eq!(theme.row_alt.fg, Some(PackedRgba::RED));
3384 assert_eq!(theme.row_selected.fg, Some(PackedRgba::RED));
3385 assert_eq!(theme.row_hover.fg, Some(PackedRgba::RED));
3386 assert_eq!(theme.divider.fg, Some(PackedRgba::RED));
3387 }
3388
3389 #[test]
3390 fn with_effects_replaces_all() {
3391 let theme = TableTheme::graphite()
3392 .with_effect(TableEffectRule::new(
3393 TableEffectTarget::AllRows,
3394 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
3395 ))
3396 .with_effects(vec![]);
3397 assert!(theme.effects.is_empty());
3398 }
3399
3400 #[test]
3401 fn different_presets_have_different_hashes() {
3402 let aurora = TableTheme::aurora().style_hash();
3403 let neon = TableTheme::neon().style_hash();
3404 let graphite = TableTheme::graphite().style_hash();
3405 assert_ne!(aurora, neon);
3406 assert_ne!(aurora, graphite);
3407 assert_ne!(neon, graphite);
3408 }
3409
3410 #[test]
3411 fn all_presets_construct_without_panic() {
3412 let presets = [
3413 TablePresetId::Aurora,
3414 TablePresetId::Graphite,
3415 TablePresetId::Neon,
3416 TablePresetId::Slate,
3417 TablePresetId::Solar,
3418 TablePresetId::Orchard,
3419 TablePresetId::Paper,
3420 TablePresetId::Midnight,
3421 TablePresetId::TerminalClassic,
3422 ];
3423 for id in presets {
3424 let theme = TableTheme::preset(id);
3425 assert_eq!(theme.preset_id, Some(id));
3426 assert_eq!(theme.padding, 1);
3427 assert_eq!(theme.column_gap, 1);
3428 assert_eq!(theme.row_height, 1);
3429 assert!(theme.effects.is_empty());
3430 }
3431 }
3432
3433 #[test]
3434 fn table_preset_id_traits() {
3435 let id = TablePresetId::Aurora;
3436 let debug = format!("{:?}", id);
3437 assert!(debug.contains("Aurora"));
3438 let cloned = id;
3439 assert_eq!(id, cloned);
3440 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3442 id.hash(&mut hasher);
3443 }
3444
3445 #[test]
3446 fn table_section_traits() {
3447 let s = TableSection::Footer;
3448 let debug = format!("{:?}", s);
3449 assert!(debug.contains("Footer"));
3450 assert_eq!(s, TableSection::Footer);
3451 assert_ne!(s, TableSection::Header);
3452 }
3453
3454 #[test]
3455 fn table_effect_target_traits() {
3456 let t = TableEffectTarget::AllCells;
3457 let debug = format!("{:?}", t);
3458 assert!(debug.contains("AllCells"));
3459 assert_eq!(t, TableEffectTarget::AllCells);
3460 }
3461
3462 #[test]
3463 fn table_effect_scope_traits() {
3464 let s = TableEffectScope::section(TableSection::Body);
3465 let debug = format!("{:?}", s);
3466 assert!(debug.contains("Body"));
3467 let cloned = s;
3468 assert_eq!(s, cloned);
3469 }
3470
3471 #[test]
3472 fn style_mask_traits() {
3473 let m = StyleMask::all();
3474 let debug = format!("{:?}", m);
3475 assert!(debug.contains("StyleMask"));
3476 let cloned = m;
3477 assert_eq!(m, cloned);
3478 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3480 m.hash(&mut hasher);
3481 }
3482
3483 #[test]
3484 fn style_attr_all_variants() {
3485 let attrs = [
3486 StyleAttr::Bold,
3487 StyleAttr::Dim,
3488 StyleAttr::Italic,
3489 StyleAttr::Underline,
3490 StyleAttr::Blink,
3491 StyleAttr::Reverse,
3492 StyleAttr::Hidden,
3493 StyleAttr::Strikethrough,
3494 StyleAttr::DoubleUnderline,
3495 StyleAttr::CurlyUnderline,
3496 ];
3497 for attr in &attrs {
3498 let debug = format!("{:?}", attr);
3499 assert!(!debug.is_empty());
3500 }
3501 assert_eq!(attrs[0], StyleAttr::Bold);
3502 assert_ne!(attrs[0], attrs[1]);
3503 }
3504
3505 #[test]
3506 fn skew_phase_clamps_extreme_asymmetry() {
3507 for asym in [-1.5, -0.9, 0.9, 1.5] {
3509 for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
3510 let result = skew_phase(t, asym);
3511 assert!(
3512 (-0.01..=1.01).contains(&result),
3513 "skew_phase({t}, {asym}) = {result}"
3514 );
3515 }
3516 }
3517 }
3518
3519 #[test]
3520 fn normalize_phase_negative_large() {
3521 let result = normalize_phase(-100.7);
3522 assert!((0.0..1.0).contains(&result), "result: {result}");
3523 }
3524
3525 #[test]
3526 fn table_theme_clone() {
3527 let theme = TableTheme::aurora().with_effect(TableEffectRule::new(
3528 TableEffectTarget::AllRows,
3529 pulse_effect(PackedRgba::RED, PackedRgba::BLACK),
3530 ));
3531 let cloned = theme.clone();
3532 assert_eq!(cloned.style_hash(), theme.style_hash());
3533 assert_eq!(cloned.effects_hash(), theme.effects_hash());
3534 assert_eq!(cloned.preset_id, theme.preset_id);
3535 }
3536}