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 #[must_use]
169 pub fn stops(&self) -> &[(f32, PackedRgba)] {
170 &self.stops
171 }
172
173 #[must_use]
175 pub fn sample(&self, t: f32) -> PackedRgba {
176 let t = t.clamp(0.0, 1.0);
177 let Some(first) = self.stops.first() else {
178 return PackedRgba::TRANSPARENT;
179 };
180 if t <= first.0 {
181 return first.1;
182 }
183 let Some(last) = self.stops.last() else {
184 return first.1;
185 };
186 if t >= last.0 {
187 return last.1;
188 }
189
190 for window in self.stops.windows(2) {
191 let (p0, c0) = window[0];
192 let (p1, c1) = window[1];
193 if t <= p1 {
194 let denom = p1 - p0;
195 if denom <= f32::EPSILON {
196 return c1;
197 }
198 let local = (t - p0) / denom;
199 return lerp_color(c0, c1, local);
200 }
201 }
202
203 last.1
204 }
205}
206
207#[derive(Clone, Debug)]
209pub enum TableEffect {
210 Pulse {
212 fg_a: PackedRgba,
213 fg_b: PackedRgba,
214 bg_a: PackedRgba,
215 bg_b: PackedRgba,
216 speed: f32,
217 phase_offset: f32,
218 },
219 BreathingGlow {
221 fg: PackedRgba,
222 bg: PackedRgba,
223 intensity: f32,
224 speed: f32,
225 phase_offset: f32,
226 asymmetry: f32,
227 },
228 GradientSweep {
230 gradient: Gradient,
231 speed: f32,
232 phase_offset: f32,
233 },
234}
235
236#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
238#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
239pub enum BlendMode {
240 #[default]
241 Replace,
242 Additive,
243 Multiply,
244 Screen,
245}
246
247#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
249#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
250#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
251pub struct StyleMask {
252 pub fg: bool,
253 pub bg: bool,
254 pub attrs: bool,
255}
256
257impl StyleMask {
258 #[must_use]
260 pub const fn fg_bg() -> Self {
261 Self {
262 fg: true,
263 bg: true,
264 attrs: false,
265 }
266 }
267
268 #[must_use]
270 pub const fn all() -> Self {
271 Self {
272 fg: true,
273 bg: true,
274 attrs: true,
275 }
276 }
277
278 #[must_use]
280 pub const fn none() -> Self {
281 Self {
282 fg: false,
283 bg: false,
284 attrs: false,
285 }
286 }
287}
288
289impl Default for StyleMask {
290 fn default() -> Self {
291 Self::fg_bg()
292 }
293}
294
295#[derive(Clone, Debug)]
297pub struct TableEffectRule {
298 pub target: TableEffectTarget,
300 pub effect: TableEffect,
302 pub priority: u8,
304 pub blend_mode: BlendMode,
306 pub style_mask: StyleMask,
308}
309
310impl TableEffectRule {
311 #[must_use]
313 pub fn new(target: TableEffectTarget, effect: TableEffect) -> Self {
314 Self {
315 target,
316 effect,
317 priority: 0,
318 blend_mode: BlendMode::default(),
319 style_mask: StyleMask::default(),
320 }
321 }
322
323 #[must_use]
325 pub fn priority(mut self, priority: u8) -> Self {
326 self.priority = priority;
327 self
328 }
329
330 #[must_use]
332 pub fn blend_mode(mut self, blend_mode: BlendMode) -> Self {
333 self.blend_mode = blend_mode;
334 self
335 }
336
337 #[must_use]
339 pub fn style_mask(mut self, style_mask: StyleMask) -> Self {
340 self.style_mask = style_mask;
341 self
342 }
343}
344
345pub struct TableEffectResolver<'a> {
349 theme: &'a TableTheme,
350}
351
352impl<'a> TableEffectResolver<'a> {
353 #[must_use]
355 pub const fn new(theme: &'a TableTheme) -> Self {
356 Self { theme }
357 }
358
359 #[must_use]
361 pub fn resolve(&self, base: Style, scope: TableEffectScope, phase: f32) -> Style {
362 resolve_effects_for_scope(self.theme, base, scope, phase)
363 }
364}
365
366#[derive(Clone, Debug)]
417pub struct TableTheme {
418 pub border: Style,
420 pub header: Style,
422 pub row: Style,
424 pub row_alt: Style,
426 pub row_selected: Style,
428 pub row_hover: Style,
430 pub divider: Style,
432 pub padding: u8,
434 pub column_gap: u8,
436 pub row_height: u8,
438 pub effects: Vec<TableEffectRule>,
440 pub preset_id: Option<TablePresetId>,
442}
443
444#[derive(Clone, Debug)]
446pub struct TableThemeDiagnostics {
447 pub preset_id: Option<TablePresetId>,
448 pub style_hash: u64,
449 pub effects_hash: u64,
450 pub effect_count: usize,
451 pub padding: u8,
452 pub column_gap: u8,
453 pub row_height: u8,
454}
455
456#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
465#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
466#[derive(Clone, Debug, PartialEq)]
467pub struct TableThemeSpec {
468 pub version: u8,
470 pub name: Option<String>,
472 pub preset_id: Option<TablePresetId>,
474 pub padding: u8,
476 pub column_gap: u8,
477 pub row_height: u8,
478 pub styles: TableThemeStyleSpec,
480 pub effects: Vec<TableEffectRuleSpec>,
482}
483
484#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
485#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
486#[derive(Clone, Debug, PartialEq)]
487pub struct TableThemeStyleSpec {
488 pub border: StyleSpec,
489 pub header: StyleSpec,
490 pub row: StyleSpec,
491 pub row_alt: StyleSpec,
492 pub row_selected: StyleSpec,
493 pub row_hover: StyleSpec,
494 pub divider: StyleSpec,
495}
496
497#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
498#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
499#[derive(Clone, Debug, PartialEq)]
500pub struct StyleSpec {
501 pub fg: Option<RgbaSpec>,
502 pub bg: Option<RgbaSpec>,
503 pub underline: Option<RgbaSpec>,
504 pub attrs: Vec<StyleAttr>,
505}
506
507#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
508#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
509pub enum StyleAttr {
510 Bold,
511 Dim,
512 Italic,
513 Underline,
514 Blink,
515 Reverse,
516 Hidden,
517 Strikethrough,
518 DoubleUnderline,
519 CurlyUnderline,
520}
521
522#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
523#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
524#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
525pub struct RgbaSpec {
526 pub r: u8,
527 pub g: u8,
528 pub b: u8,
529 pub a: u8,
530}
531
532impl RgbaSpec {
533 #[must_use]
534 pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
535 Self { r, g, b, a }
536 }
537}
538
539impl From<PackedRgba> for RgbaSpec {
540 fn from(color: PackedRgba) -> Self {
541 Self::new(color.r(), color.g(), color.b(), color.a())
542 }
543}
544
545impl From<RgbaSpec> for PackedRgba {
546 fn from(color: RgbaSpec) -> Self {
547 PackedRgba::rgba(color.r, color.g, color.b, color.a)
548 }
549}
550
551#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
552#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
553#[derive(Clone, Debug, PartialEq)]
554pub struct GradientSpec {
555 pub stops: Vec<GradientStopSpec>,
556}
557
558#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
559#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
560#[derive(Clone, Copy, Debug, PartialEq)]
561pub struct GradientStopSpec {
562 pub pos: f32,
563 pub color: RgbaSpec,
564}
565
566#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
567#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
568#[derive(Clone, Debug, PartialEq)]
569pub enum TableEffectSpec {
570 Pulse {
571 fg_a: RgbaSpec,
572 fg_b: RgbaSpec,
573 bg_a: RgbaSpec,
574 bg_b: RgbaSpec,
575 speed: f32,
576 phase_offset: f32,
577 },
578 BreathingGlow {
579 fg: RgbaSpec,
580 bg: RgbaSpec,
581 intensity: f32,
582 speed: f32,
583 phase_offset: f32,
584 asymmetry: f32,
585 },
586 GradientSweep {
587 gradient: GradientSpec,
588 speed: f32,
589 phase_offset: f32,
590 },
591}
592
593#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
594#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
595#[derive(Clone, Debug, PartialEq)]
596pub struct TableEffectRuleSpec {
597 pub target: TableEffectTarget,
598 pub effect: TableEffectSpec,
599 pub priority: u8,
600 pub blend_mode: BlendMode,
601 pub style_mask: StyleMask,
602}
603
604pub const TABLE_THEME_SPEC_VERSION: u8 = 1;
606const TABLE_THEME_SPEC_MAX_NAME_LEN: usize = 64;
607const TABLE_THEME_SPEC_MAX_EFFECTS: usize = 64;
608const TABLE_THEME_SPEC_MAX_STYLE_ATTRS: usize = 16;
609const TABLE_THEME_SPEC_MAX_GRADIENT_STOPS: usize = 16;
610const TABLE_THEME_SPEC_MIN_GRADIENT_STOPS: usize = 1;
611const TABLE_THEME_SPEC_MAX_PADDING: u8 = 8;
612const TABLE_THEME_SPEC_MAX_COLUMN_GAP: u8 = 8;
613const TABLE_THEME_SPEC_MIN_ROW_HEIGHT: u8 = 1;
614const TABLE_THEME_SPEC_MAX_ROW_HEIGHT: u8 = 8;
615const TABLE_THEME_SPEC_MAX_SPEED: f32 = 10.0;
616const TABLE_THEME_SPEC_MAX_PHASE: f32 = 1.0;
617const TABLE_THEME_SPEC_MAX_INTENSITY: f32 = 1.0;
618const TABLE_THEME_SPEC_MAX_ASYMMETRY: f32 = 0.9;
619
620#[derive(Debug, Clone, PartialEq, Eq)]
621pub struct TableThemeSpecError {
622 pub field: String,
623 pub message: String,
624}
625
626impl TableThemeSpecError {
627 fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
628 Self {
629 field: field.into(),
630 message: message.into(),
631 }
632 }
633}
634
635impl std::fmt::Display for TableThemeSpecError {
636 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
637 write!(f, "{}: {}", self.field, self.message)
638 }
639}
640
641impl std::error::Error for TableThemeSpecError {}
642
643impl TableThemeSpec {
644 #[must_use]
646 pub fn from_theme(theme: &TableTheme) -> Self {
647 Self {
648 version: TABLE_THEME_SPEC_VERSION,
649 name: None,
650 preset_id: theme.preset_id,
651 padding: theme.padding,
652 column_gap: theme.column_gap,
653 row_height: theme.row_height,
654 styles: TableThemeStyleSpec {
655 border: StyleSpec::from_style(&theme.border),
656 header: StyleSpec::from_style(&theme.header),
657 row: StyleSpec::from_style(&theme.row),
658 row_alt: StyleSpec::from_style(&theme.row_alt),
659 row_selected: StyleSpec::from_style(&theme.row_selected),
660 row_hover: StyleSpec::from_style(&theme.row_hover),
661 divider: StyleSpec::from_style(&theme.divider),
662 },
663 effects: theme
664 .effects
665 .iter()
666 .map(TableEffectRuleSpec::from_rule)
667 .collect(),
668 }
669 }
670
671 #[must_use]
673 pub fn into_theme(self) -> TableTheme {
674 TableTheme {
675 border: self.styles.border.to_style(),
676 header: self.styles.header.to_style(),
677 row: self.styles.row.to_style(),
678 row_alt: self.styles.row_alt.to_style(),
679 row_selected: self.styles.row_selected.to_style(),
680 row_hover: self.styles.row_hover.to_style(),
681 divider: self.styles.divider.to_style(),
682 padding: self.padding,
683 column_gap: self.column_gap,
684 row_height: self.row_height,
685 effects: self
686 .effects
687 .into_iter()
688 .map(|spec| spec.to_rule())
689 .collect(),
690 preset_id: self.preset_id,
691 }
692 }
693
694 pub fn validate(&self) -> Result<(), TableThemeSpecError> {
696 if self.version != TABLE_THEME_SPEC_VERSION {
697 return Err(TableThemeSpecError::new(
698 "version",
699 format!("unsupported version {}", self.version),
700 ));
701 }
702
703 if let Some(name) = &self.name
704 && name.len() > TABLE_THEME_SPEC_MAX_NAME_LEN
705 {
706 return Err(TableThemeSpecError::new(
707 "name",
708 format!(
709 "name length {} exceeds max {}",
710 name.len(),
711 TABLE_THEME_SPEC_MAX_NAME_LEN
712 ),
713 ));
714 }
715
716 validate_u8_range("padding", self.padding, 0, TABLE_THEME_SPEC_MAX_PADDING)?;
717 validate_u8_range(
718 "column_gap",
719 self.column_gap,
720 0,
721 TABLE_THEME_SPEC_MAX_COLUMN_GAP,
722 )?;
723 validate_u8_range(
724 "row_height",
725 self.row_height,
726 TABLE_THEME_SPEC_MIN_ROW_HEIGHT,
727 TABLE_THEME_SPEC_MAX_ROW_HEIGHT,
728 )?;
729
730 validate_style_spec(&self.styles.border, "styles.border")?;
731 validate_style_spec(&self.styles.header, "styles.header")?;
732 validate_style_spec(&self.styles.row, "styles.row")?;
733 validate_style_spec(&self.styles.row_alt, "styles.row_alt")?;
734 validate_style_spec(&self.styles.row_selected, "styles.row_selected")?;
735 validate_style_spec(&self.styles.row_hover, "styles.row_hover")?;
736 validate_style_spec(&self.styles.divider, "styles.divider")?;
737
738 if self.effects.len() > TABLE_THEME_SPEC_MAX_EFFECTS {
739 return Err(TableThemeSpecError::new(
740 "effects",
741 format!(
742 "effect count {} exceeds max {}",
743 self.effects.len(),
744 TABLE_THEME_SPEC_MAX_EFFECTS
745 ),
746 ));
747 }
748
749 for (idx, rule) in self.effects.iter().enumerate() {
750 validate_effect_rule(rule, idx)?;
751 }
752
753 Ok(())
754 }
755}
756
757fn validate_u8_range(
758 field: impl Into<String>,
759 value: u8,
760 min: u8,
761 max: u8,
762) -> Result<(), TableThemeSpecError> {
763 if value < min || value > max {
764 return Err(TableThemeSpecError::new(
765 field,
766 format!("value {} outside range [{}..={}]", value, min, max),
767 ));
768 }
769 Ok(())
770}
771
772fn validate_style_spec(style: &StyleSpec, field: &str) -> Result<(), TableThemeSpecError> {
773 if style.attrs.len() > TABLE_THEME_SPEC_MAX_STYLE_ATTRS {
774 return Err(TableThemeSpecError::new(
775 format!("{field}.attrs"),
776 format!(
777 "attr count {} exceeds max {}",
778 style.attrs.len(),
779 TABLE_THEME_SPEC_MAX_STYLE_ATTRS
780 ),
781 ));
782 }
783 Ok(())
784}
785
786fn validate_effect_target(
787 target: &TableEffectTarget,
788 idx: usize,
789) -> Result<(), TableThemeSpecError> {
790 let base = format!("effects[{idx}].target");
791 match *target {
792 TableEffectTarget::RowRange { start, end } => {
793 if start > end {
794 return Err(TableThemeSpecError::new(
795 format!("{base}.row_range"),
796 "start must be <= end",
797 ));
798 }
799 }
800 TableEffectTarget::ColumnRange { start, end } => {
801 if start > end {
802 return Err(TableThemeSpecError::new(
803 format!("{base}.column_range"),
804 "start must be <= end",
805 ));
806 }
807 }
808 _ => {}
809 }
810 Ok(())
811}
812
813fn validate_effect_rule(rule: &TableEffectRuleSpec, idx: usize) -> Result<(), TableThemeSpecError> {
814 validate_effect_target(&rule.target, idx)?;
815 let base = format!("effects[{idx}].effect");
816 match &rule.effect {
817 TableEffectSpec::Pulse {
818 speed,
819 phase_offset,
820 ..
821 } => {
822 validate_f32_range(
823 format!("{base}.speed"),
824 *speed,
825 0.0,
826 TABLE_THEME_SPEC_MAX_SPEED,
827 )?;
828 validate_f32_range(
829 format!("{base}.phase_offset"),
830 *phase_offset,
831 0.0,
832 TABLE_THEME_SPEC_MAX_PHASE,
833 )?;
834 }
835 TableEffectSpec::BreathingGlow {
836 intensity,
837 speed,
838 phase_offset,
839 asymmetry,
840 ..
841 } => {
842 validate_f32_range(
843 format!("{base}.intensity"),
844 *intensity,
845 0.0,
846 TABLE_THEME_SPEC_MAX_INTENSITY,
847 )?;
848 validate_f32_range(
849 format!("{base}.speed"),
850 *speed,
851 0.0,
852 TABLE_THEME_SPEC_MAX_SPEED,
853 )?;
854 validate_f32_range(
855 format!("{base}.phase_offset"),
856 *phase_offset,
857 0.0,
858 TABLE_THEME_SPEC_MAX_PHASE,
859 )?;
860 validate_f32_range(
861 format!("{base}.asymmetry"),
862 *asymmetry,
863 -TABLE_THEME_SPEC_MAX_ASYMMETRY,
864 TABLE_THEME_SPEC_MAX_ASYMMETRY,
865 )?;
866 }
867 TableEffectSpec::GradientSweep {
868 gradient,
869 speed,
870 phase_offset,
871 } => {
872 validate_gradient_spec(gradient, &base)?;
873 validate_f32_range(
874 format!("{base}.speed"),
875 *speed,
876 0.0,
877 TABLE_THEME_SPEC_MAX_SPEED,
878 )?;
879 validate_f32_range(
880 format!("{base}.phase_offset"),
881 *phase_offset,
882 0.0,
883 TABLE_THEME_SPEC_MAX_PHASE,
884 )?;
885 }
886 }
887 Ok(())
888}
889
890fn validate_gradient_spec(gradient: &GradientSpec, base: &str) -> Result<(), TableThemeSpecError> {
891 let count = gradient.stops.len();
892 if !(TABLE_THEME_SPEC_MIN_GRADIENT_STOPS..=TABLE_THEME_SPEC_MAX_GRADIENT_STOPS).contains(&count)
893 {
894 return Err(TableThemeSpecError::new(
895 format!("{base}.gradient.stops"),
896 format!(
897 "stop count {} outside range [{}..={}]",
898 count, TABLE_THEME_SPEC_MIN_GRADIENT_STOPS, TABLE_THEME_SPEC_MAX_GRADIENT_STOPS
899 ),
900 ));
901 }
902 for (idx, stop) in gradient.stops.iter().enumerate() {
903 validate_f32_range(
904 format!("{base}.gradient.stops[{idx}].pos"),
905 stop.pos,
906 0.0,
907 1.0,
908 )?;
909 }
910 Ok(())
911}
912
913fn validate_f32_range(
914 field: impl Into<String>,
915 value: f32,
916 min: f32,
917 max: f32,
918) -> Result<(), TableThemeSpecError> {
919 if !value.is_finite() {
920 return Err(TableThemeSpecError::new(field, "value must be finite"));
921 }
922 if value < min || value > max {
923 return Err(TableThemeSpecError::new(
924 field,
925 format!("value {} outside range [{min}..={max}]", value),
926 ));
927 }
928 Ok(())
929}
930
931impl StyleSpec {
932 #[must_use]
933 pub fn from_style(style: &Style) -> Self {
934 Self {
935 fg: style.fg.map(RgbaSpec::from),
936 bg: style.bg.map(RgbaSpec::from),
937 underline: style.underline_color.map(RgbaSpec::from),
938 attrs: style.attrs.map(attrs_from_flags).unwrap_or_default(),
939 }
940 }
941
942 #[must_use]
943 pub fn to_style(&self) -> Style {
944 let mut style = Style::new();
945 style.fg = self.fg.map(PackedRgba::from);
946 style.bg = self.bg.map(PackedRgba::from);
947 style.underline_color = self.underline.map(PackedRgba::from);
948 style.attrs = flags_from_attrs(&self.attrs);
949 style
950 }
951}
952
953impl GradientSpec {
954 #[must_use]
955 pub fn from_gradient(gradient: &Gradient) -> Self {
956 Self {
957 stops: gradient
958 .stops()
959 .iter()
960 .map(|(pos, color)| GradientStopSpec {
961 pos: *pos,
962 color: RgbaSpec::from(*color),
963 })
964 .collect(),
965 }
966 }
967
968 #[must_use]
969 pub fn to_gradient(&self) -> Gradient {
970 Gradient::new(
971 self.stops
972 .iter()
973 .map(|stop| (stop.pos, PackedRgba::from(stop.color)))
974 .collect(),
975 )
976 }
977}
978
979impl TableEffectSpec {
980 #[must_use]
981 pub fn from_effect(effect: &TableEffect) -> Self {
982 match effect {
983 TableEffect::Pulse {
984 fg_a,
985 fg_b,
986 bg_a,
987 bg_b,
988 speed,
989 phase_offset,
990 } => Self::Pulse {
991 fg_a: (*fg_a).into(),
992 fg_b: (*fg_b).into(),
993 bg_a: (*bg_a).into(),
994 bg_b: (*bg_b).into(),
995 speed: *speed,
996 phase_offset: *phase_offset,
997 },
998 TableEffect::BreathingGlow {
999 fg,
1000 bg,
1001 intensity,
1002 speed,
1003 phase_offset,
1004 asymmetry,
1005 } => Self::BreathingGlow {
1006 fg: (*fg).into(),
1007 bg: (*bg).into(),
1008 intensity: *intensity,
1009 speed: *speed,
1010 phase_offset: *phase_offset,
1011 asymmetry: *asymmetry,
1012 },
1013 TableEffect::GradientSweep {
1014 gradient,
1015 speed,
1016 phase_offset,
1017 } => Self::GradientSweep {
1018 gradient: GradientSpec::from_gradient(gradient),
1019 speed: *speed,
1020 phase_offset: *phase_offset,
1021 },
1022 }
1023 }
1024
1025 #[must_use]
1026 pub fn to_effect(&self) -> TableEffect {
1027 match self {
1028 TableEffectSpec::Pulse {
1029 fg_a,
1030 fg_b,
1031 bg_a,
1032 bg_b,
1033 speed,
1034 phase_offset,
1035 } => TableEffect::Pulse {
1036 fg_a: (*fg_a).into(),
1037 fg_b: (*fg_b).into(),
1038 bg_a: (*bg_a).into(),
1039 bg_b: (*bg_b).into(),
1040 speed: *speed,
1041 phase_offset: *phase_offset,
1042 },
1043 TableEffectSpec::BreathingGlow {
1044 fg,
1045 bg,
1046 intensity,
1047 speed,
1048 phase_offset,
1049 asymmetry,
1050 } => TableEffect::BreathingGlow {
1051 fg: (*fg).into(),
1052 bg: (*bg).into(),
1053 intensity: *intensity,
1054 speed: *speed,
1055 phase_offset: *phase_offset,
1056 asymmetry: *asymmetry,
1057 },
1058 TableEffectSpec::GradientSweep {
1059 gradient,
1060 speed,
1061 phase_offset,
1062 } => TableEffect::GradientSweep {
1063 gradient: gradient.to_gradient(),
1064 speed: *speed,
1065 phase_offset: *phase_offset,
1066 },
1067 }
1068 }
1069}
1070
1071impl TableEffectRuleSpec {
1072 #[must_use]
1073 pub fn from_rule(rule: &TableEffectRule) -> Self {
1074 Self {
1075 target: rule.target,
1076 effect: TableEffectSpec::from_effect(&rule.effect),
1077 priority: rule.priority,
1078 blend_mode: rule.blend_mode,
1079 style_mask: rule.style_mask,
1080 }
1081 }
1082
1083 #[must_use]
1084 pub fn to_rule(&self) -> TableEffectRule {
1085 TableEffectRule {
1086 target: self.target,
1087 effect: self.effect.to_effect(),
1088 priority: self.priority,
1089 blend_mode: self.blend_mode,
1090 style_mask: self.style_mask,
1091 }
1092 }
1093}
1094
1095fn attrs_from_flags(flags: StyleFlags) -> Vec<StyleAttr> {
1096 let mut attrs = Vec::new();
1097 if flags.contains(StyleFlags::BOLD) {
1098 attrs.push(StyleAttr::Bold);
1099 }
1100 if flags.contains(StyleFlags::DIM) {
1101 attrs.push(StyleAttr::Dim);
1102 }
1103 if flags.contains(StyleFlags::ITALIC) {
1104 attrs.push(StyleAttr::Italic);
1105 }
1106 if flags.contains(StyleFlags::UNDERLINE) {
1107 attrs.push(StyleAttr::Underline);
1108 }
1109 if flags.contains(StyleFlags::BLINK) {
1110 attrs.push(StyleAttr::Blink);
1111 }
1112 if flags.contains(StyleFlags::REVERSE) {
1113 attrs.push(StyleAttr::Reverse);
1114 }
1115 if flags.contains(StyleFlags::HIDDEN) {
1116 attrs.push(StyleAttr::Hidden);
1117 }
1118 if flags.contains(StyleFlags::STRIKETHROUGH) {
1119 attrs.push(StyleAttr::Strikethrough);
1120 }
1121 if flags.contains(StyleFlags::DOUBLE_UNDERLINE) {
1122 attrs.push(StyleAttr::DoubleUnderline);
1123 }
1124 if flags.contains(StyleFlags::CURLY_UNDERLINE) {
1125 attrs.push(StyleAttr::CurlyUnderline);
1126 }
1127 attrs
1128}
1129
1130fn flags_from_attrs(attrs: &[StyleAttr]) -> Option<StyleFlags> {
1131 if attrs.is_empty() {
1132 return None;
1133 }
1134 let mut flags = StyleFlags::NONE;
1135 for attr in attrs {
1136 match attr {
1137 StyleAttr::Bold => flags.insert(StyleFlags::BOLD),
1138 StyleAttr::Dim => flags.insert(StyleFlags::DIM),
1139 StyleAttr::Italic => flags.insert(StyleFlags::ITALIC),
1140 StyleAttr::Underline => flags.insert(StyleFlags::UNDERLINE),
1141 StyleAttr::Blink => flags.insert(StyleFlags::BLINK),
1142 StyleAttr::Reverse => flags.insert(StyleFlags::REVERSE),
1143 StyleAttr::Hidden => flags.insert(StyleFlags::HIDDEN),
1144 StyleAttr::Strikethrough => flags.insert(StyleFlags::STRIKETHROUGH),
1145 StyleAttr::DoubleUnderline => flags.insert(StyleFlags::DOUBLE_UNDERLINE),
1146 StyleAttr::CurlyUnderline => flags.insert(StyleFlags::CURLY_UNDERLINE),
1147 }
1148 }
1149 if flags.is_empty() { None } else { Some(flags) }
1150}
1151
1152struct ThemeStyles {
1153 border: Style,
1154 header: Style,
1155 row: Style,
1156 row_alt: Style,
1157 row_selected: Style,
1158 row_hover: Style,
1159 divider: Style,
1160}
1161
1162impl TableTheme {
1163 #[must_use]
1165 pub const fn effect_resolver(&self) -> TableEffectResolver<'_> {
1166 TableEffectResolver::new(self)
1167 }
1168
1169 #[must_use]
1171 pub fn preset(preset: TablePresetId) -> Self {
1172 match preset {
1173 TablePresetId::Aurora => Self::aurora(),
1174 TablePresetId::Graphite => Self::graphite(),
1175 TablePresetId::Neon => Self::neon(),
1176 TablePresetId::Slate => Self::slate(),
1177 TablePresetId::Solar => Self::solar(),
1178 TablePresetId::Orchard => Self::orchard(),
1179 TablePresetId::Paper => Self::paper(),
1180 TablePresetId::Midnight => Self::midnight(),
1181 TablePresetId::TerminalClassic => Self::terminal_classic(),
1182 }
1183 }
1184
1185 #[must_use]
1187 pub fn with_border(mut self, border: Style) -> Self {
1188 self.border = border;
1189 self
1190 }
1191
1192 #[must_use]
1194 pub fn with_header(mut self, header: Style) -> Self {
1195 self.header = header;
1196 self
1197 }
1198
1199 #[must_use]
1201 pub fn with_row(mut self, row: Style) -> Self {
1202 self.row = row;
1203 self
1204 }
1205
1206 #[must_use]
1208 pub fn with_row_alt(mut self, row_alt: Style) -> Self {
1209 self.row_alt = row_alt;
1210 self
1211 }
1212
1213 #[must_use]
1215 pub fn with_row_selected(mut self, row_selected: Style) -> Self {
1216 self.row_selected = row_selected;
1217 self
1218 }
1219
1220 #[must_use]
1222 pub fn with_row_hover(mut self, row_hover: Style) -> Self {
1223 self.row_hover = row_hover;
1224 self
1225 }
1226
1227 #[must_use]
1229 pub fn with_divider(mut self, divider: Style) -> Self {
1230 self.divider = divider;
1231 self
1232 }
1233
1234 #[must_use]
1236 pub fn with_padding(mut self, padding: u8) -> Self {
1237 self.padding = padding;
1238 self
1239 }
1240
1241 #[must_use]
1243 pub fn with_column_gap(mut self, column_gap: u8) -> Self {
1244 self.column_gap = column_gap;
1245 self
1246 }
1247
1248 #[must_use]
1250 pub fn with_row_height(mut self, row_height: u8) -> Self {
1251 self.row_height = row_height;
1252 self
1253 }
1254
1255 #[must_use]
1257 pub fn with_effects(mut self, effects: Vec<TableEffectRule>) -> Self {
1258 self.effects = effects;
1259 self
1260 }
1261
1262 #[must_use]
1264 pub fn with_effect(mut self, effect: TableEffectRule) -> Self {
1265 self.effects.push(effect);
1266 self
1267 }
1268
1269 #[must_use]
1271 pub fn clear_effects(mut self) -> Self {
1272 self.effects.clear();
1273 self
1274 }
1275
1276 #[must_use]
1278 pub fn with_preset_id(mut self, preset_id: Option<TablePresetId>) -> Self {
1279 self.preset_id = preset_id;
1280 self
1281 }
1282
1283 #[must_use]
1285 pub fn aurora() -> Self {
1286 Self::build(
1287 TablePresetId::Aurora,
1288 ThemeStyles {
1289 border: Style::new().fg(PackedRgba::rgb(130, 170, 210)),
1290 header: Style::new()
1291 .fg(PackedRgba::rgb(250, 250, 255))
1292 .bg(PackedRgba::rgb(70, 100, 140))
1293 .bold(),
1294 row: Style::new().fg(PackedRgba::rgb(230, 235, 245)),
1295 row_alt: Style::new()
1296 .fg(PackedRgba::rgb(230, 235, 245))
1297 .bg(PackedRgba::rgb(28, 36, 54)),
1298 row_selected: Style::new()
1299 .fg(PackedRgba::rgb(255, 255, 255))
1300 .bg(PackedRgba::rgb(50, 90, 140))
1301 .bold(),
1302 row_hover: Style::new()
1303 .fg(PackedRgba::rgb(240, 245, 255))
1304 .bg(PackedRgba::rgb(40, 70, 110)),
1305 divider: Style::new().fg(PackedRgba::rgb(90, 120, 160)),
1306 },
1307 )
1308 }
1309
1310 #[must_use]
1312 pub fn graphite() -> Self {
1313 Self::build(
1314 TablePresetId::Graphite,
1315 ThemeStyles {
1316 border: Style::new().fg(PackedRgba::rgb(140, 140, 140)),
1317 header: Style::new()
1318 .fg(PackedRgba::rgb(240, 240, 240))
1319 .bg(PackedRgba::rgb(70, 70, 70))
1320 .bold(),
1321 row: Style::new().fg(PackedRgba::rgb(220, 220, 220)),
1322 row_alt: Style::new()
1323 .fg(PackedRgba::rgb(220, 220, 220))
1324 .bg(PackedRgba::rgb(35, 35, 35)),
1325 row_selected: Style::new()
1326 .fg(PackedRgba::rgb(255, 255, 255))
1327 .bg(PackedRgba::rgb(90, 90, 90)),
1328 row_hover: Style::new()
1329 .fg(PackedRgba::rgb(245, 245, 245))
1330 .bg(PackedRgba::rgb(60, 60, 60)),
1331 divider: Style::new().fg(PackedRgba::rgb(120, 120, 120)),
1332 },
1333 )
1334 }
1335
1336 #[must_use]
1338 pub fn neon() -> Self {
1339 Self::build(
1340 TablePresetId::Neon,
1341 ThemeStyles {
1342 border: Style::new().fg(PackedRgba::rgb(120, 255, 230)),
1343 header: Style::new()
1344 .fg(PackedRgba::rgb(10, 10, 15))
1345 .bg(PackedRgba::rgb(0, 255, 200))
1346 .bold(),
1347 row: Style::new().fg(PackedRgba::rgb(210, 255, 245)),
1348 row_alt: Style::new()
1349 .fg(PackedRgba::rgb(210, 255, 245))
1350 .bg(PackedRgba::rgb(10, 20, 30)),
1351 row_selected: Style::new()
1352 .fg(PackedRgba::rgb(5, 5, 10))
1353 .bg(PackedRgba::rgb(255, 0, 200))
1354 .bold(),
1355 row_hover: Style::new()
1356 .fg(PackedRgba::rgb(0, 10, 15))
1357 .bg(PackedRgba::rgb(0, 200, 255)),
1358 divider: Style::new().fg(PackedRgba::rgb(80, 220, 200)),
1359 },
1360 )
1361 }
1362
1363 #[must_use]
1365 pub fn slate() -> Self {
1366 Self::build(
1367 TablePresetId::Slate,
1368 ThemeStyles {
1369 border: Style::new().fg(PackedRgba::rgb(120, 130, 140)),
1370 header: Style::new()
1371 .fg(PackedRgba::rgb(230, 235, 240))
1372 .bg(PackedRgba::rgb(60, 70, 80))
1373 .bold(),
1374 row: Style::new().fg(PackedRgba::rgb(210, 215, 220)),
1375 row_alt: Style::new()
1376 .fg(PackedRgba::rgb(210, 215, 220))
1377 .bg(PackedRgba::rgb(30, 35, 40)),
1378 row_selected: Style::new()
1379 .fg(PackedRgba::rgb(255, 255, 255))
1380 .bg(PackedRgba::rgb(80, 90, 110)),
1381 row_hover: Style::new()
1382 .fg(PackedRgba::rgb(235, 240, 245))
1383 .bg(PackedRgba::rgb(50, 60, 70)),
1384 divider: Style::new().fg(PackedRgba::rgb(110, 120, 130)),
1385 },
1386 )
1387 }
1388
1389 #[must_use]
1391 pub fn solar() -> Self {
1392 Self::build(
1393 TablePresetId::Solar,
1394 ThemeStyles {
1395 border: Style::new().fg(PackedRgba::rgb(200, 170, 120)),
1396 header: Style::new()
1397 .fg(PackedRgba::rgb(30, 25, 10))
1398 .bg(PackedRgba::rgb(255, 200, 90))
1399 .bold(),
1400 row: Style::new().fg(PackedRgba::rgb(240, 220, 180)),
1401 row_alt: Style::new()
1402 .fg(PackedRgba::rgb(240, 220, 180))
1403 .bg(PackedRgba::rgb(60, 40, 20)),
1404 row_selected: Style::new()
1405 .fg(PackedRgba::rgb(20, 10, 0))
1406 .bg(PackedRgba::rgb(255, 140, 60))
1407 .bold(),
1408 row_hover: Style::new()
1409 .fg(PackedRgba::rgb(20, 10, 0))
1410 .bg(PackedRgba::rgb(220, 120, 40)),
1411 divider: Style::new().fg(PackedRgba::rgb(170, 140, 90)),
1412 },
1413 )
1414 }
1415
1416 #[must_use]
1418 pub fn orchard() -> Self {
1419 Self::build(
1420 TablePresetId::Orchard,
1421 ThemeStyles {
1422 border: Style::new().fg(PackedRgba::rgb(140, 180, 120)),
1423 header: Style::new()
1424 .fg(PackedRgba::rgb(20, 40, 20))
1425 .bg(PackedRgba::rgb(120, 200, 120))
1426 .bold(),
1427 row: Style::new().fg(PackedRgba::rgb(210, 235, 210)),
1428 row_alt: Style::new()
1429 .fg(PackedRgba::rgb(210, 235, 210))
1430 .bg(PackedRgba::rgb(30, 60, 40)),
1431 row_selected: Style::new()
1432 .fg(PackedRgba::rgb(15, 30, 15))
1433 .bg(PackedRgba::rgb(160, 230, 140))
1434 .bold(),
1435 row_hover: Style::new()
1436 .fg(PackedRgba::rgb(15, 30, 15))
1437 .bg(PackedRgba::rgb(130, 210, 120)),
1438 divider: Style::new().fg(PackedRgba::rgb(100, 150, 100)),
1439 },
1440 )
1441 }
1442
1443 #[must_use]
1445 pub fn paper() -> Self {
1446 Self::build(
1447 TablePresetId::Paper,
1448 ThemeStyles {
1449 border: Style::new().fg(PackedRgba::rgb(120, 110, 100)),
1450 header: Style::new()
1451 .fg(PackedRgba::rgb(30, 30, 30))
1452 .bg(PackedRgba::rgb(230, 220, 200))
1453 .bold(),
1454 row: Style::new()
1455 .fg(PackedRgba::rgb(40, 40, 40))
1456 .bg(PackedRgba::rgb(245, 240, 230)),
1457 row_alt: Style::new()
1458 .fg(PackedRgba::rgb(40, 40, 40))
1459 .bg(PackedRgba::rgb(235, 230, 220)),
1460 row_selected: Style::new()
1461 .fg(PackedRgba::rgb(10, 10, 10))
1462 .bg(PackedRgba::rgb(255, 245, 210))
1463 .bold(),
1464 row_hover: Style::new()
1465 .fg(PackedRgba::rgb(20, 20, 20))
1466 .bg(PackedRgba::rgb(245, 235, 205)),
1467 divider: Style::new().fg(PackedRgba::rgb(140, 130, 120)),
1468 },
1469 )
1470 }
1471
1472 #[must_use]
1474 pub fn midnight() -> Self {
1475 Self::build(
1476 TablePresetId::Midnight,
1477 ThemeStyles {
1478 border: Style::new().fg(PackedRgba::rgb(80, 100, 130)),
1479 header: Style::new()
1480 .fg(PackedRgba::rgb(220, 230, 255))
1481 .bg(PackedRgba::rgb(30, 40, 70))
1482 .bold(),
1483 row: Style::new().fg(PackedRgba::rgb(200, 210, 230)),
1484 row_alt: Style::new()
1485 .fg(PackedRgba::rgb(200, 210, 230))
1486 .bg(PackedRgba::rgb(15, 20, 35)),
1487 row_selected: Style::new()
1488 .fg(PackedRgba::rgb(255, 255, 255))
1489 .bg(PackedRgba::rgb(60, 80, 120))
1490 .bold(),
1491 row_hover: Style::new()
1492 .fg(PackedRgba::rgb(240, 240, 255))
1493 .bg(PackedRgba::rgb(45, 60, 90)),
1494 divider: Style::new().fg(PackedRgba::rgb(100, 120, 150)),
1495 },
1496 )
1497 }
1498
1499 #[must_use]
1501 pub fn terminal_classic() -> Self {
1502 Self::terminal_classic_for(ColorProfile::detect())
1503 }
1504
1505 #[must_use]
1507 pub fn terminal_classic_for(profile: ColorProfile) -> Self {
1508 let border = classic_color(profile, (160, 160, 160), Ansi16::BrightBlack);
1509 let header_fg = classic_color(profile, (245, 245, 245), Ansi16::BrightWhite);
1510 let header_bg = classic_color(profile, (0, 90, 140), Ansi16::Blue);
1511 let row_fg = classic_color(profile, (230, 230, 230), Ansi16::White);
1512 let row_alt_bg = classic_color(profile, (30, 30, 30), Ansi16::Black);
1513 let selected_bg = classic_color(profile, (160, 90, 10), Ansi16::Yellow);
1514 let hover_bg = classic_color(profile, (70, 70, 70), Ansi16::BrightBlack);
1515 let divider = classic_color(profile, (120, 120, 120), Ansi16::BrightBlack);
1516
1517 Self::build(
1518 TablePresetId::TerminalClassic,
1519 ThemeStyles {
1520 border: Style::new().fg(border),
1521 header: Style::new().fg(header_fg).bg(header_bg).bold(),
1522 row: Style::new().fg(row_fg),
1523 row_alt: Style::new().fg(row_fg).bg(row_alt_bg),
1524 row_selected: Style::new().fg(PackedRgba::BLACK).bg(selected_bg).bold(),
1525 row_hover: Style::new().fg(PackedRgba::WHITE).bg(hover_bg),
1526 divider: Style::new().fg(divider),
1527 },
1528 )
1529 }
1530
1531 fn build(preset_id: TablePresetId, styles: ThemeStyles) -> Self {
1532 Self {
1533 border: styles.border,
1534 header: styles.header,
1535 row: styles.row,
1536 row_alt: styles.row_alt,
1537 row_selected: styles.row_selected,
1538 row_hover: styles.row_hover,
1539 divider: styles.divider,
1540 padding: 1,
1541 column_gap: 1,
1542 row_height: 1,
1543 effects: Vec::new(),
1544 preset_id: Some(preset_id),
1545 }
1546 }
1547
1548 #[must_use]
1550 pub fn diagnostics(&self) -> TableThemeDiagnostics {
1551 TableThemeDiagnostics {
1552 preset_id: self.preset_id,
1553 style_hash: self.style_hash(),
1554 effects_hash: self.effects_hash(),
1555 effect_count: self.effects.len(),
1556 padding: self.padding,
1557 column_gap: self.column_gap,
1558 row_height: self.row_height,
1559 }
1560 }
1561
1562 #[must_use]
1564 pub fn style_hash(&self) -> u64 {
1565 let mut hasher = StableHasher::new();
1566 hash_style(&self.border, &mut hasher);
1567 hash_style(&self.header, &mut hasher);
1568 hash_style(&self.row, &mut hasher);
1569 hash_style(&self.row_alt, &mut hasher);
1570 hash_style(&self.row_selected, &mut hasher);
1571 hash_style(&self.row_hover, &mut hasher);
1572 hash_style(&self.divider, &mut hasher);
1573 hash_u8(self.padding, &mut hasher);
1574 hash_u8(self.column_gap, &mut hasher);
1575 hash_u8(self.row_height, &mut hasher);
1576 hash_preset(self.preset_id, &mut hasher);
1577 hasher.finish()
1578 }
1579
1580 #[must_use]
1582 pub fn effects_hash(&self) -> u64 {
1583 let mut hasher = StableHasher::new();
1584 hash_usize(self.effects.len(), &mut hasher);
1585 for rule in &self.effects {
1586 hash_effect_rule(rule, &mut hasher);
1587 }
1588 hasher.finish()
1589 }
1590}
1591
1592#[derive(Clone, Copy, Debug)]
1593struct EffectSample {
1594 fg: Option<PackedRgba>,
1595 bg: Option<PackedRgba>,
1596 alpha: f32,
1597}
1598
1599#[inline]
1600fn resolve_effects_for_scope(
1601 theme: &TableTheme,
1602 base: Style,
1603 scope: TableEffectScope,
1604 phase: f32,
1605) -> Style {
1606 if theme.effects.is_empty() {
1607 return base;
1608 }
1609
1610 let mut min_priority = u8::MAX;
1611 let mut max_priority = 0;
1612 for rule in &theme.effects {
1613 min_priority = min_priority.min(rule.priority);
1614 max_priority = max_priority.max(rule.priority);
1615 }
1616 if min_priority == u8::MAX {
1617 return base;
1618 }
1619
1620 let mut resolved = base;
1621 for priority in min_priority..=max_priority {
1622 for rule in &theme.effects {
1623 if rule.priority != priority {
1624 continue;
1625 }
1626 if !rule.target.matches_scope(scope) {
1627 continue;
1628 }
1629 resolved = apply_effect_rule(resolved, rule, phase);
1630 }
1631 }
1632
1633 resolved
1634}
1635
1636#[inline]
1637fn apply_effect_rule(mut base: Style, rule: &TableEffectRule, phase: f32) -> Style {
1638 let sample = sample_effect(&rule.effect, phase);
1639 let alpha = sample.alpha.clamp(0.0, 1.0);
1640 if alpha <= 0.0 {
1641 return base;
1642 }
1643
1644 if rule.style_mask.fg {
1645 base.fg = apply_channel(base.fg, sample.fg, alpha, rule.blend_mode);
1646 }
1647 if rule.style_mask.bg {
1648 base.bg = apply_channel(base.bg, sample.bg, alpha, rule.blend_mode);
1649 }
1650 base
1651}
1652
1653#[inline]
1654fn apply_channel(
1655 base: Option<PackedRgba>,
1656 effect: Option<PackedRgba>,
1657 alpha: f32,
1658 blend_mode: BlendMode,
1659) -> Option<PackedRgba> {
1660 let effect = effect?;
1661 let alpha = alpha.clamp(0.0, 1.0);
1662 let result = match base {
1663 Some(base) => blend_with_alpha(base, effect, alpha, blend_mode),
1664 None => with_alpha(effect, alpha),
1665 };
1666 Some(result)
1667}
1668
1669#[inline]
1670fn blend_with_alpha(
1671 base: PackedRgba,
1672 effect: PackedRgba,
1673 alpha: f32,
1674 blend_mode: BlendMode,
1675) -> PackedRgba {
1676 let alpha = alpha.clamp(0.0, 1.0);
1677 match blend_mode {
1678 BlendMode::Replace => lerp_color(base, effect, alpha),
1679 BlendMode::Additive => blend_additive(with_alpha(effect, alpha), base),
1680 BlendMode::Multiply => blend_multiply(with_alpha(effect, alpha), base),
1681 BlendMode::Screen => blend_screen(with_alpha(effect, alpha), base),
1682 }
1683}
1684
1685#[inline]
1686fn sample_effect(effect: &TableEffect, phase: f32) -> EffectSample {
1687 match *effect {
1688 TableEffect::Pulse {
1689 fg_a,
1690 fg_b,
1691 bg_a,
1692 bg_b,
1693 speed,
1694 phase_offset,
1695 } => {
1696 let t = normalize_phase(phase * speed + phase_offset);
1697 let alpha = pulse_curve(t);
1698 EffectSample {
1699 fg: Some(lerp_color(fg_a, fg_b, alpha)),
1700 bg: Some(lerp_color(bg_a, bg_b, alpha)),
1701 alpha: 1.0,
1702 }
1703 }
1704 TableEffect::BreathingGlow {
1705 fg,
1706 bg,
1707 intensity,
1708 speed,
1709 phase_offset,
1710 asymmetry,
1711 } => {
1712 let t = normalize_phase(phase * speed + phase_offset);
1713 let alpha = (breathing_curve(t, asymmetry) * intensity).clamp(0.0, 1.0);
1714 EffectSample {
1715 fg: Some(fg),
1716 bg: Some(bg),
1717 alpha,
1718 }
1719 }
1720 TableEffect::GradientSweep {
1721 ref gradient,
1722 speed,
1723 phase_offset,
1724 } => {
1725 let t = normalize_phase(phase * speed + phase_offset);
1726 let color = gradient.sample(t);
1727 EffectSample {
1728 fg: Some(color),
1729 bg: Some(color),
1730 alpha: 1.0,
1731 }
1732 }
1733 }
1734}
1735
1736#[inline]
1737fn normalize_phase(phase: f32) -> f32 {
1738 phase.rem_euclid(1.0)
1739}
1740
1741#[inline]
1742fn pulse_curve(t: f32) -> f32 {
1743 0.5 - 0.5 * (std::f32::consts::TAU * t).cos()
1744}
1745
1746#[inline]
1747fn breathing_curve(t: f32, asymmetry: f32) -> f32 {
1748 let t = skew_phase(t, asymmetry);
1749 0.5 - 0.5 * (std::f32::consts::TAU * t).cos()
1750}
1751
1752#[inline]
1753fn skew_phase(t: f32, asymmetry: f32) -> f32 {
1754 let skew = asymmetry.clamp(-0.9, 0.9);
1755 if skew == 0.0 {
1756 return t;
1757 }
1758 if skew > 0.0 {
1759 t.powf(1.0 + skew * 2.0)
1760 } else {
1761 1.0 - (1.0 - t).powf(1.0 - skew * 2.0)
1762 }
1763}
1764
1765#[inline]
1766fn with_alpha(color: PackedRgba, alpha: f32) -> PackedRgba {
1767 let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
1768 PackedRgba::rgba(color.r(), color.g(), color.b(), a)
1769}
1770
1771#[inline]
1772fn blend_additive(top: PackedRgba, bottom: PackedRgba) -> PackedRgba {
1773 let ta = top.a() as f32 / 255.0;
1774 let r = (bottom.r() as f32 + top.r() as f32 * ta).min(255.0) as u8;
1775 let g = (bottom.g() as f32 + top.g() as f32 * ta).min(255.0) as u8;
1776 let b = (bottom.b() as f32 + top.b() as f32 * ta).min(255.0) as u8;
1777 let a = bottom.a().max(top.a());
1778 PackedRgba::rgba(r, g, b, a)
1779}
1780
1781#[inline]
1782fn blend_multiply(top: PackedRgba, bottom: PackedRgba) -> PackedRgba {
1783 let ta = top.a() as f32 / 255.0;
1784 let mr = (top.r() as f32 * bottom.r() as f32 / 255.0) as u8;
1785 let mg = (top.g() as f32 * bottom.g() as f32 / 255.0) as u8;
1786 let mb = (top.b() as f32 * bottom.b() as f32 / 255.0) as u8;
1787 let r = (bottom.r() as f32 * (1.0 - ta) + mr as f32 * ta) as u8;
1788 let g = (bottom.g() as f32 * (1.0 - ta) + mg as f32 * ta) as u8;
1789 let b = (bottom.b() as f32 * (1.0 - ta) + mb as f32 * ta) as u8;
1790 let a = bottom.a().max(top.a());
1791 PackedRgba::rgba(r, g, b, a)
1792}
1793
1794#[inline]
1795fn blend_screen(top: PackedRgba, bottom: PackedRgba) -> PackedRgba {
1796 let ta = top.a() as f32 / 255.0;
1797 let sr = 255 - ((255 - top.r()) as u16 * (255 - bottom.r()) as u16 / 255) as u8;
1798 let sg = 255 - ((255 - top.g()) as u16 * (255 - bottom.g()) as u16 / 255) as u8;
1799 let sb = 255 - ((255 - top.b()) as u16 * (255 - bottom.b()) as u16 / 255) as u8;
1800 let r = (bottom.r() as f32 * (1.0 - ta) + sr as f32 * ta) as u8;
1801 let g = (bottom.g() as f32 * (1.0 - ta) + sg as f32 * ta) as u8;
1802 let b = (bottom.b() as f32 * (1.0 - ta) + sb as f32 * ta) as u8;
1803 let a = bottom.a().max(top.a());
1804 PackedRgba::rgba(r, g, b, a)
1805}
1806
1807impl Default for TableTheme {
1808 fn default() -> Self {
1809 Self::graphite()
1810 }
1811}
1812
1813#[inline]
1814fn classic_color(profile: ColorProfile, rgb: (u8, u8, u8), ansi16: Ansi16) -> PackedRgba {
1815 let color = match profile {
1816 ColorProfile::Ansi16 => Color::Ansi16(ansi16),
1817 _ => Color::rgb(rgb.0, rgb.1, rgb.2).downgrade(profile),
1818 };
1819 let rgb = color.to_rgb();
1820 PackedRgba::rgb(rgb.r, rgb.g, rgb.b)
1821}
1822
1823#[derive(Clone, Copy, Debug)]
1828struct StableHasher {
1829 state: u64,
1830}
1831
1832impl StableHasher {
1833 const OFFSET: u64 = 0xcbf29ce484222325;
1834 const PRIME: u64 = 0x100000001b3;
1835
1836 #[must_use]
1837 const fn new() -> Self {
1838 Self {
1839 state: Self::OFFSET,
1840 }
1841 }
1842}
1843
1844impl Hasher for StableHasher {
1845 fn finish(&self) -> u64 {
1846 self.state
1847 }
1848
1849 fn write(&mut self, bytes: &[u8]) {
1850 let mut hash = self.state;
1851 for byte in bytes {
1852 hash ^= u64::from(*byte);
1853 hash = hash.wrapping_mul(Self::PRIME);
1854 }
1855 self.state = hash;
1856 }
1857}
1858
1859fn hash_u8(value: u8, hasher: &mut StableHasher) {
1860 hasher.write(&[value]);
1861}
1862
1863fn hash_u32(value: u32, hasher: &mut StableHasher) {
1864 hasher.write(&value.to_le_bytes());
1865}
1866
1867fn hash_u64(value: u64, hasher: &mut StableHasher) {
1868 hasher.write(&value.to_le_bytes());
1869}
1870
1871fn hash_usize(value: usize, hasher: &mut StableHasher) {
1872 hash_u64(value as u64, hasher);
1873}
1874
1875fn hash_f32(value: f32, hasher: &mut StableHasher) {
1876 hash_u32(value.to_bits(), hasher);
1877}
1878
1879fn hash_bool(value: bool, hasher: &mut StableHasher) {
1880 hash_u8(value as u8, hasher);
1881}
1882
1883fn hash_style(style: &Style, hasher: &mut StableHasher) {
1884 style.hash(hasher);
1885}
1886
1887fn hash_packed_rgba(color: PackedRgba, hasher: &mut StableHasher) {
1888 hash_u32(color.0, hasher);
1889}
1890
1891fn hash_preset(preset: Option<TablePresetId>, hasher: &mut StableHasher) {
1892 match preset {
1893 None => hash_u8(0, hasher),
1894 Some(id) => {
1895 hash_u8(1, hasher);
1896 hash_table_preset(id, hasher);
1897 }
1898 }
1899}
1900
1901fn hash_table_preset(preset: TablePresetId, hasher: &mut StableHasher) {
1902 let tag = match preset {
1903 TablePresetId::Aurora => 1,
1904 TablePresetId::Graphite => 2,
1905 TablePresetId::Neon => 3,
1906 TablePresetId::Slate => 4,
1907 TablePresetId::Solar => 5,
1908 TablePresetId::Orchard => 6,
1909 TablePresetId::Paper => 7,
1910 TablePresetId::Midnight => 8,
1911 TablePresetId::TerminalClassic => 9,
1912 };
1913 hash_u8(tag, hasher);
1914}
1915
1916fn hash_table_section(section: TableSection, hasher: &mut StableHasher) {
1917 let tag = match section {
1918 TableSection::Header => 1,
1919 TableSection::Body => 2,
1920 TableSection::Footer => 3,
1921 };
1922 hash_u8(tag, hasher);
1923}
1924
1925fn hash_blend_mode(mode: BlendMode, hasher: &mut StableHasher) {
1926 let tag = match mode {
1927 BlendMode::Replace => 1,
1928 BlendMode::Additive => 2,
1929 BlendMode::Multiply => 3,
1930 BlendMode::Screen => 4,
1931 };
1932 hash_u8(tag, hasher);
1933}
1934
1935fn hash_style_mask(mask: StyleMask, hasher: &mut StableHasher) {
1936 hash_bool(mask.fg, hasher);
1937 hash_bool(mask.bg, hasher);
1938 hash_bool(mask.attrs, hasher);
1939}
1940
1941fn hash_effect_target(target: &TableEffectTarget, hasher: &mut StableHasher) {
1942 match *target {
1943 TableEffectTarget::Section(section) => {
1944 hash_u8(1, hasher);
1945 hash_table_section(section, hasher);
1946 }
1947 TableEffectTarget::Row(row) => {
1948 hash_u8(2, hasher);
1949 hash_usize(row, hasher);
1950 }
1951 TableEffectTarget::RowRange { start, end } => {
1952 hash_u8(3, hasher);
1953 hash_usize(start, hasher);
1954 hash_usize(end, hasher);
1955 }
1956 TableEffectTarget::Column(column) => {
1957 hash_u8(4, hasher);
1958 hash_usize(column, hasher);
1959 }
1960 TableEffectTarget::ColumnRange { start, end } => {
1961 hash_u8(5, hasher);
1962 hash_usize(start, hasher);
1963 hash_usize(end, hasher);
1964 }
1965 TableEffectTarget::AllRows => {
1966 hash_u8(6, hasher);
1967 }
1968 TableEffectTarget::AllCells => {
1969 hash_u8(7, hasher);
1970 }
1971 }
1972}
1973
1974fn hash_gradient(gradient: &Gradient, hasher: &mut StableHasher) {
1975 hash_usize(gradient.stops.len(), hasher);
1976 for (pos, color) in &gradient.stops {
1977 hash_f32(*pos, hasher);
1978 hash_packed_rgba(*color, hasher);
1979 }
1980}
1981
1982fn hash_effect(effect: &TableEffect, hasher: &mut StableHasher) {
1983 match *effect {
1984 TableEffect::Pulse {
1985 fg_a,
1986 fg_b,
1987 bg_a,
1988 bg_b,
1989 speed,
1990 phase_offset,
1991 } => {
1992 hash_u8(1, hasher);
1993 hash_packed_rgba(fg_a, hasher);
1994 hash_packed_rgba(fg_b, hasher);
1995 hash_packed_rgba(bg_a, hasher);
1996 hash_packed_rgba(bg_b, hasher);
1997 hash_f32(speed, hasher);
1998 hash_f32(phase_offset, hasher);
1999 }
2000 TableEffect::BreathingGlow {
2001 fg,
2002 bg,
2003 intensity,
2004 speed,
2005 phase_offset,
2006 asymmetry,
2007 } => {
2008 hash_u8(2, hasher);
2009 hash_packed_rgba(fg, hasher);
2010 hash_packed_rgba(bg, hasher);
2011 hash_f32(intensity, hasher);
2012 hash_f32(speed, hasher);
2013 hash_f32(phase_offset, hasher);
2014 hash_f32(asymmetry, hasher);
2015 }
2016 TableEffect::GradientSweep {
2017 ref gradient,
2018 speed,
2019 phase_offset,
2020 } => {
2021 hash_u8(3, hasher);
2022 hash_gradient(gradient, hasher);
2023 hash_f32(speed, hasher);
2024 hash_f32(phase_offset, hasher);
2025 }
2026 }
2027}
2028
2029fn hash_effect_rule(rule: &TableEffectRule, hasher: &mut StableHasher) {
2030 hash_effect_target(&rule.target, hasher);
2031 hash_effect(&rule.effect, hasher);
2032 hash_u8(rule.priority, hasher);
2033 hash_blend_mode(rule.blend_mode, hasher);
2034 hash_style_mask(rule.style_mask, hasher);
2035}
2036
2037#[cfg(test)]
2038mod tests {
2039 use super::*;
2040 use crate::color::{WCAG_AA_LARGE_TEXT, WCAG_AA_NORMAL_TEXT, contrast_ratio_packed};
2041
2042 fn base_bg(theme: &TableTheme) -> PackedRgba {
2043 theme
2044 .row
2045 .bg
2046 .or(theme.row_alt.bg)
2047 .or(theme.header.bg)
2048 .or(theme.row_selected.bg)
2049 .or(theme.row_hover.bg)
2050 .unwrap_or(PackedRgba::BLACK)
2051 }
2052
2053 fn expect_fg(preset: TablePresetId, label: &str, style: Style) -> PackedRgba {
2054 style
2055 .fg
2056 .unwrap_or_else(|| panic!("{preset:?} missing fg for {label}"))
2057 }
2058
2059 fn expect_bg(preset: TablePresetId, label: &str, style: Style) -> PackedRgba {
2060 style
2061 .bg
2062 .unwrap_or_else(|| panic!("{preset:?} missing bg for {label}"))
2063 }
2064
2065 fn assert_contrast(
2066 preset: TablePresetId,
2067 label: &str,
2068 fg: PackedRgba,
2069 bg: PackedRgba,
2070 minimum: f64,
2071 ) {
2072 let ratio = contrast_ratio_packed(fg, bg);
2073 assert!(
2074 ratio >= minimum,
2075 "{preset:?} {label} contrast {ratio:.2} below {minimum:.2}"
2076 );
2077 }
2078
2079 fn pulse_effect(fg: PackedRgba, bg: PackedRgba) -> TableEffect {
2080 TableEffect::Pulse {
2081 fg_a: fg,
2082 fg_b: fg,
2083 bg_a: bg,
2084 bg_b: bg,
2085 speed: 1.0,
2086 phase_offset: 0.0,
2087 }
2088 }
2089
2090 fn assert_f32_near(label: &str, value: f32, expected: f32) {
2091 let delta = (value - expected).abs();
2092 assert!(delta <= 1e-6, "{label} expected {expected}, got {value}");
2093 }
2094
2095 #[test]
2096 fn style_mask_default_is_fg_bg() {
2097 let mask = StyleMask::default();
2098 assert!(mask.fg);
2099 assert!(mask.bg);
2100 assert!(!mask.attrs);
2101 }
2102
2103 #[test]
2104 fn effect_target_matches_scope_variants() {
2105 let row_scope = TableEffectScope::row(TableSection::Body, 2);
2106 assert!(TableEffectTarget::Section(TableSection::Body).matches_scope(row_scope));
2107 assert!(!TableEffectTarget::Section(TableSection::Header).matches_scope(row_scope));
2108 assert!(TableEffectTarget::Row(2).matches_scope(row_scope));
2109 assert!(!TableEffectTarget::Row(1).matches_scope(row_scope));
2110 assert!(TableEffectTarget::RowRange { start: 1, end: 3 }.matches_scope(row_scope));
2111 assert!(!TableEffectTarget::RowRange { start: 3, end: 5 }.matches_scope(row_scope));
2112 assert!(TableEffectTarget::AllRows.matches_scope(row_scope));
2113 assert!(TableEffectTarget::AllCells.matches_scope(row_scope));
2114 assert!(!TableEffectTarget::Column(0).matches_scope(row_scope));
2115
2116 let col_scope = TableEffectScope::column(TableSection::Header, 1);
2117 assert!(TableEffectTarget::Column(1).matches_scope(col_scope));
2118 assert!(TableEffectTarget::ColumnRange { start: 0, end: 2 }.matches_scope(col_scope));
2119 assert!(!TableEffectTarget::AllRows.matches_scope(col_scope));
2120 assert!(TableEffectTarget::AllCells.matches_scope(col_scope));
2121
2122 let footer_scope = TableEffectScope::row(TableSection::Footer, 0);
2123 assert!(!TableEffectTarget::AllCells.matches_scope(footer_scope));
2124
2125 let header_section = TableEffectScope::section(TableSection::Header);
2126 assert!(!TableEffectTarget::AllCells.matches_scope(header_section));
2127 }
2128
2129 #[test]
2130 fn effect_resolver_returns_base_without_effects() {
2131 let base = Style::new()
2132 .fg(PackedRgba::rgb(12, 34, 56))
2133 .bg(PackedRgba::rgb(7, 8, 9));
2134 let mut theme = TableTheme::aurora();
2135 theme.effects.clear();
2136
2137 let resolver = theme.effect_resolver();
2138 let scope = TableEffectScope::row(TableSection::Body, 0);
2139 let resolved = resolver.resolve(base, scope, 0.25);
2140 assert_eq!(resolved, base);
2141 }
2142
2143 #[test]
2144 fn effect_resolver_all_rows_excludes_header() {
2145 let base = Style::new().fg(PackedRgba::rgb(10, 10, 10));
2146 let mut theme = TableTheme::aurora();
2147 theme.effects = vec![TableEffectRule::new(
2149 TableEffectTarget::AllRows,
2150 pulse_effect(PackedRgba::rgb(200, 0, 0), PackedRgba::rgb(5, 5, 5)),
2151 )];
2152
2153 let resolver = theme.effect_resolver();
2154 let header_scope = TableEffectScope::row(TableSection::Header, 0);
2155 let body_scope = TableEffectScope::row(TableSection::Body, 0);
2156
2157 let header = resolver.resolve(base, header_scope, 0.5);
2158 let body = resolver.resolve(base, body_scope, 0.5);
2159 assert_eq!(header, base);
2160 assert_eq!(body.fg, Some(PackedRgba::rgb(200, 0, 0)));
2161 }
2162
2163 #[test]
2164 fn effect_resolver_all_cells_includes_header_rows() {
2165 let base = Style::new().fg(PackedRgba::rgb(10, 10, 10));
2166 let mut theme = TableTheme::aurora();
2167 theme.effects = vec![TableEffectRule::new(
2169 TableEffectTarget::AllCells,
2170 pulse_effect(PackedRgba::rgb(0, 200, 0), PackedRgba::rgb(5, 5, 5)),
2171 )];
2172
2173 let resolver = theme.effect_resolver();
2174 let header_scope = TableEffectScope::row(TableSection::Header, 0);
2175 let resolved = resolver.resolve(base, header_scope, 0.5);
2176 assert_eq!(resolved.fg, Some(PackedRgba::rgb(0, 200, 0)));
2177 }
2178
2179 #[test]
2180 fn normalize_phase_wraps_and_curves_are_deterministic() {
2181 assert_f32_near("normalize_phase(-0.25)", normalize_phase(-0.25), 0.75);
2182 assert_f32_near("normalize_phase(1.25)", normalize_phase(1.25), 0.25);
2183 assert_f32_near("pulse_curve(0.0)", pulse_curve(0.0), 0.0);
2184 assert_f32_near("pulse_curve(0.5)", pulse_curve(0.5), 1.0);
2185 assert_f32_near(
2186 "breathing_curve matches pulse at zero asymmetry",
2187 breathing_curve(0.25, 0.0),
2188 pulse_curve(0.25),
2189 );
2190 }
2191
2192 #[test]
2193 fn lerp_color_clamps_out_of_range_t() {
2194 let a = PackedRgba::rgb(0, 0, 0);
2195 let b = PackedRgba::rgb(255, 255, 255);
2196 assert_eq!(lerp_color(a, b, -1.0), a);
2197 assert_eq!(lerp_color(a, b, 2.0), b);
2198 }
2199
2200 #[test]
2201 fn effect_resolver_respects_priority_order() {
2202 let base = Style::new()
2203 .fg(PackedRgba::rgb(10, 10, 10))
2204 .bg(PackedRgba::rgb(20, 20, 20));
2205 let mut theme = TableTheme::aurora();
2206 theme.effects = vec![
2207 TableEffectRule::new(
2208 TableEffectTarget::AllRows,
2209 pulse_effect(PackedRgba::rgb(200, 0, 0), PackedRgba::rgb(0, 0, 0)),
2210 )
2211 .priority(0),
2212 TableEffectRule::new(
2213 TableEffectTarget::AllRows,
2214 pulse_effect(PackedRgba::rgb(0, 0, 200), PackedRgba::rgb(0, 0, 80)),
2215 )
2216 .priority(5),
2217 ];
2218
2219 let resolver = theme.effect_resolver();
2220 let scope = TableEffectScope::row(TableSection::Body, 0);
2221 let resolved = resolver.resolve(base, scope, 0.0);
2222 assert_eq!(resolved.fg, Some(PackedRgba::rgb(0, 0, 200)));
2223 assert_eq!(resolved.bg, Some(PackedRgba::rgb(0, 0, 80)));
2224 }
2225
2226 #[test]
2227 fn effect_resolver_applies_same_priority_in_list_order() {
2228 let base = Style::new().fg(PackedRgba::rgb(5, 5, 5));
2229 let mut theme = TableTheme::aurora();
2230 theme.effects = vec![
2231 TableEffectRule::new(
2232 TableEffectTarget::Row(0),
2233 pulse_effect(PackedRgba::rgb(10, 10, 10), PackedRgba::BLACK),
2234 )
2235 .priority(1),
2236 TableEffectRule::new(
2237 TableEffectTarget::Row(0),
2238 pulse_effect(PackedRgba::rgb(40, 40, 40), PackedRgba::BLACK),
2239 )
2240 .priority(1),
2241 ];
2242
2243 let resolver = theme.effect_resolver();
2244 let scope = TableEffectScope::row(TableSection::Body, 0);
2245 let resolved = resolver.resolve(base, scope, 0.0);
2246 assert_eq!(resolved.fg, Some(PackedRgba::rgb(40, 40, 40)));
2247 }
2248
2249 #[test]
2250 fn effect_resolver_respects_style_mask() {
2251 let base = Style::new()
2252 .fg(PackedRgba::rgb(10, 20, 30))
2253 .bg(PackedRgba::rgb(1, 2, 3));
2254 let mut theme = TableTheme::aurora();
2255 theme.effects = vec![
2256 TableEffectRule::new(
2257 TableEffectTarget::Row(0),
2258 pulse_effect(PackedRgba::rgb(200, 100, 0), PackedRgba::rgb(9, 9, 9)),
2259 )
2260 .style_mask(StyleMask::none()),
2261 ];
2262
2263 let resolver = theme.effect_resolver();
2264 let scope = TableEffectScope::row(TableSection::Body, 0);
2265 let resolved = resolver.resolve(base, scope, 0.0);
2266 assert_eq!(resolved, base);
2267
2268 theme.effects = vec![
2269 TableEffectRule::new(
2270 TableEffectTarget::Row(0),
2271 pulse_effect(PackedRgba::rgb(200, 100, 0), PackedRgba::rgb(9, 9, 9)),
2272 )
2273 .style_mask(StyleMask {
2274 fg: true,
2275 bg: false,
2276 attrs: false,
2277 }),
2278 ];
2279 let resolver = theme.effect_resolver();
2280 let resolved = resolver.resolve(base, scope, 0.0);
2281 assert_eq!(resolved.fg, Some(PackedRgba::rgb(200, 100, 0)));
2282 assert_eq!(resolved.bg, base.bg);
2283 }
2284
2285 #[test]
2286 fn effect_resolver_skips_alpha_zero() {
2287 let base = Style::new()
2288 .fg(PackedRgba::rgb(10, 10, 10))
2289 .bg(PackedRgba::rgb(20, 20, 20));
2290 let mut theme = TableTheme::aurora();
2291 theme.effects = vec![TableEffectRule::new(
2292 TableEffectTarget::Row(0),
2293 TableEffect::BreathingGlow {
2294 fg: PackedRgba::rgb(200, 200, 200),
2295 bg: PackedRgba::rgb(10, 10, 10),
2296 intensity: 0.0,
2297 speed: 1.0,
2298 phase_offset: 0.0,
2299 asymmetry: 0.0,
2300 },
2301 )];
2302
2303 let resolver = theme.effect_resolver();
2304 let scope = TableEffectScope::row(TableSection::Body, 0);
2305 let resolved = resolver.resolve(base, scope, 0.5);
2306 assert_eq!(resolved, base);
2307 }
2308
2309 #[test]
2310 fn presets_set_preset_id() {
2311 let theme = TableTheme::aurora();
2312 assert_eq!(theme.preset_id, Some(TablePresetId::Aurora));
2313 }
2314
2315 #[test]
2316 fn terminal_classic_keeps_profile() {
2317 let theme = TableTheme::terminal_classic_for(ColorProfile::Ansi16);
2318 assert_eq!(theme.preset_id, Some(TablePresetId::TerminalClassic));
2319 assert!(theme.column_gap > 0);
2320 }
2321
2322 #[test]
2323 fn style_hash_is_deterministic() {
2324 let theme = TableTheme::aurora();
2325 let h1 = theme.style_hash();
2326 let h2 = theme.style_hash();
2327 assert_eq!(h1, h2, "style_hash should be stable for identical input");
2328 }
2329
2330 #[test]
2331 fn style_hash_changes_with_layout_params() {
2332 let mut theme = TableTheme::aurora();
2333 let base = theme.style_hash();
2334 theme.padding = theme.padding.saturating_add(1);
2335 assert_ne!(
2336 base,
2337 theme.style_hash(),
2338 "padding should influence style hash"
2339 );
2340 }
2341
2342 #[test]
2343 fn effects_hash_changes_with_rules() {
2344 let mut theme = TableTheme::aurora();
2345 let base = theme.effects_hash();
2346 theme.effects.push(TableEffectRule::new(
2347 TableEffectTarget::AllRows,
2348 TableEffect::BreathingGlow {
2349 fg: PackedRgba::rgb(200, 220, 255),
2350 bg: PackedRgba::rgb(30, 40, 60),
2351 intensity: 0.6,
2352 speed: 0.8,
2353 phase_offset: 0.1,
2354 asymmetry: 0.2,
2355 },
2356 ));
2357 assert_ne!(
2358 base,
2359 theme.effects_hash(),
2360 "effects hash should change with rules"
2361 );
2362 }
2363
2364 #[test]
2365 fn presets_meet_wcag_contrast_targets() {
2366 let presets = [
2367 TablePresetId::Aurora,
2368 TablePresetId::Graphite,
2369 TablePresetId::Neon,
2370 TablePresetId::Slate,
2371 TablePresetId::Solar,
2372 TablePresetId::Orchard,
2373 TablePresetId::Paper,
2374 TablePresetId::Midnight,
2375 TablePresetId::TerminalClassic,
2376 ];
2377
2378 for preset in presets {
2379 let theme = match preset {
2380 TablePresetId::TerminalClassic => {
2381 TableTheme::terminal_classic_for(ColorProfile::Ansi16)
2382 }
2383 _ => TableTheme::preset(preset),
2384 };
2385 let base = base_bg(&theme);
2386
2387 let header_fg = expect_fg(preset, "header", theme.header);
2388 let header_bg = expect_bg(preset, "header", theme.header);
2389 assert_contrast(preset, "header", header_fg, header_bg, WCAG_AA_NORMAL_TEXT);
2390
2391 let row_fg = expect_fg(preset, "row", theme.row);
2392 let row_bg = theme.row.bg.unwrap_or(base);
2393 assert_contrast(preset, "row", row_fg, row_bg, WCAG_AA_NORMAL_TEXT);
2394
2395 let row_alt_fg = expect_fg(preset, "row_alt", theme.row_alt);
2396 let row_alt_bg = expect_bg(preset, "row_alt", theme.row_alt);
2397 assert_contrast(
2398 preset,
2399 "row_alt",
2400 row_alt_fg,
2401 row_alt_bg,
2402 WCAG_AA_NORMAL_TEXT,
2403 );
2404
2405 let selected_fg = expect_fg(preset, "row_selected", theme.row_selected);
2406 let selected_bg = expect_bg(preset, "row_selected", theme.row_selected);
2407 assert_contrast(
2408 preset,
2409 "row_selected",
2410 selected_fg,
2411 selected_bg,
2412 WCAG_AA_NORMAL_TEXT,
2413 );
2414
2415 let hover_fg = expect_fg(preset, "row_hover", theme.row_hover);
2416 let hover_bg = expect_bg(preset, "row_hover", theme.row_hover);
2417 let hover_min = if preset == TablePresetId::TerminalClassic {
2418 WCAG_AA_LARGE_TEXT
2420 } else {
2421 WCAG_AA_NORMAL_TEXT
2422 };
2423 assert_contrast(preset, "row_hover", hover_fg, hover_bg, hover_min);
2424
2425 let border_fg = expect_fg(preset, "border", theme.border);
2426 assert_contrast(preset, "border", border_fg, base, WCAG_AA_LARGE_TEXT);
2427
2428 let divider_fg = expect_fg(preset, "divider", theme.divider);
2429 assert_contrast(preset, "divider", divider_fg, base, WCAG_AA_LARGE_TEXT);
2430 }
2431 }
2432
2433 fn base_spec() -> TableThemeSpec {
2434 TableThemeSpec::from_theme(&TableTheme::aurora())
2435 }
2436
2437 fn sample_rule() -> TableEffectRuleSpec {
2438 TableEffectRuleSpec {
2439 target: TableEffectTarget::AllRows,
2440 effect: TableEffectSpec::Pulse {
2441 fg_a: RgbaSpec::new(10, 20, 30, 255),
2442 fg_b: RgbaSpec::new(40, 50, 60, 255),
2443 bg_a: RgbaSpec::new(5, 5, 5, 255),
2444 bg_b: RgbaSpec::new(9, 9, 9, 255),
2445 speed: 1.0,
2446 phase_offset: 0.0,
2447 },
2448 priority: 0,
2449 blend_mode: BlendMode::Replace,
2450 style_mask: StyleMask::fg_bg(),
2451 }
2452 }
2453
2454 #[test]
2455 fn table_theme_spec_validate_accepts_defaults() {
2456 let spec = base_spec();
2457 assert!(spec.validate().is_ok());
2458 }
2459
2460 #[test]
2461 fn table_theme_spec_validate_rejects_padding_overflow() {
2462 let mut spec = base_spec();
2463 spec.padding = TABLE_THEME_SPEC_MAX_PADDING.saturating_add(1);
2464 let err = spec.validate().expect_err("expected padding range error");
2465 assert_eq!(err.field, "padding");
2466 }
2467
2468 #[test]
2469 fn table_theme_spec_validate_rejects_name_length_overflow() {
2470 let mut spec = base_spec();
2471 spec.name = Some("x".repeat(TABLE_THEME_SPEC_MAX_NAME_LEN.saturating_add(1)));
2472 let err = spec.validate().expect_err("expected name length error");
2473 assert_eq!(err.field, "name");
2474 }
2475
2476 #[test]
2477 fn table_theme_spec_validate_rejects_effect_count_overflow() {
2478 let mut spec = base_spec();
2479 spec.effects = vec![sample_rule(); TABLE_THEME_SPEC_MAX_EFFECTS.saturating_add(1)];
2480 let err = spec.validate().expect_err("expected effects length error");
2481 assert_eq!(err.field, "effects");
2482 }
2483
2484 #[test]
2485 fn table_theme_spec_validate_rejects_style_attr_overflow() {
2486 let mut spec = base_spec();
2487 spec.styles.header.attrs = vec![
2488 StyleAttr::Bold;
2489 TABLE_THEME_SPEC_MAX_STYLE_ATTRS.saturating_add(1)
2490 ];
2491 let err = spec
2492 .validate()
2493 .expect_err("expected style attr length error");
2494 assert_eq!(err.field, "styles.header.attrs");
2495 }
2496
2497 #[test]
2498 fn table_theme_spec_validate_rejects_gradient_stop_count_out_of_range() {
2499 let mut spec = base_spec();
2500 spec.effects = vec![TableEffectRuleSpec {
2501 target: TableEffectTarget::AllRows,
2502 effect: TableEffectSpec::GradientSweep {
2503 gradient: GradientSpec { stops: Vec::new() },
2504 speed: 1.0,
2505 phase_offset: 0.0,
2506 },
2507 priority: 0,
2508 blend_mode: BlendMode::Replace,
2509 style_mask: StyleMask::fg_bg(),
2510 }];
2511 let err = spec
2512 .validate()
2513 .expect_err("expected gradient stop count error");
2514 assert!(
2515 err.field.contains("gradient.stops"),
2516 "unexpected field: {}",
2517 err.field
2518 );
2519 }
2520
2521 #[test]
2522 fn table_theme_spec_validate_rejects_gradient_stop_out_of_range() {
2523 let mut spec = base_spec();
2524 spec.effects = vec![TableEffectRuleSpec {
2525 target: TableEffectTarget::AllRows,
2526 effect: TableEffectSpec::GradientSweep {
2527 gradient: GradientSpec {
2528 stops: vec![GradientStopSpec {
2529 pos: 1.5,
2530 color: RgbaSpec::new(0, 0, 0, 255),
2531 }],
2532 },
2533 speed: 1.0,
2534 phase_offset: 0.0,
2535 },
2536 priority: 0,
2537 blend_mode: BlendMode::Replace,
2538 style_mask: StyleMask::fg_bg(),
2539 }];
2540 let err = spec
2541 .validate()
2542 .expect_err("expected gradient stop range error");
2543 assert!(
2544 err.field.contains("gradient.stops"),
2545 "unexpected field: {}",
2546 err.field
2547 );
2548 }
2549
2550 #[test]
2551 fn table_theme_spec_validate_rejects_inverted_row_range() {
2552 let mut spec = base_spec();
2553 let mut rule = sample_rule();
2554 rule.target = TableEffectTarget::RowRange { start: 3, end: 1 };
2555 spec.effects = vec![rule];
2556 let err = spec.validate().expect_err("expected target range error");
2557 assert!(
2558 err.field.contains("target"),
2559 "unexpected field: {}",
2560 err.field
2561 );
2562 }
2563}