Skip to main content

ftui_style/
table_theme.rs

1#![forbid(unsafe_code)]
2
3//! TableTheme core types and preset definitions.
4
5use 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/// Built-in TableTheme preset identifiers.
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
31pub enum TablePresetId {
32    /// Luminous header with cool zebra rows.
33    Aurora,
34    /// High-contrast graphite palette for dense data.
35    Graphite,
36    /// Neon accent palette on dark base.
37    Neon,
38    /// Muted slate tones with soft dividers.
39    Slate,
40    /// Warm solar tones with bright header.
41    Solar,
42    /// Orchard-inspired greens and warm highlights.
43    Orchard,
44    /// Paper-like light theme with crisp borders.
45    Paper,
46    /// Midnight palette for dark terminals.
47    Midnight,
48    /// Classic terminal styling (ANSI-friendly).
49    TerminalClassic,
50}
51
52/// Semantic table sections.
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
55pub enum TableSection {
56    /// Header row section.
57    Header,
58    /// Body rows section.
59    Body,
60    /// Footer rows section.
61    Footer,
62}
63
64/// Target selection for a table effect.
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
67pub enum TableEffectTarget {
68    /// Apply to an entire section (header/body/footer).
69    Section(TableSection),
70    /// Apply to a specific row index.
71    Row(usize),
72    /// Apply to a row range (inclusive bounds).
73    RowRange { start: usize, end: usize },
74    /// Apply to a specific column index.
75    Column(usize),
76    /// Apply to a column range (inclusive bounds).
77    ColumnRange { start: usize, end: usize },
78    /// Body rows only.
79    AllRows,
80    /// Header + body.
81    AllCells,
82}
83
84/// Scope used to resolve table effects without per-cell work.
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
87pub struct TableEffectScope {
88    /// Section being rendered.
89    pub section: TableSection,
90    /// Optional row index within the section.
91    pub row: Option<usize>,
92    /// Optional column index within the section.
93    pub column: Option<usize>,
94}
95
96impl TableEffectScope {
97    /// Scope for a whole section (no row/column specificity).
98    #[must_use]
99    pub const fn section(section: TableSection) -> Self {
100        Self {
101            section,
102            row: None,
103            column: None,
104        }
105    }
106
107    /// Scope for a specific row within a section.
108    #[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    /// Scope for a specific column within a section.
118    #[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    /// Determine whether this target applies to the given scope.
130    #[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/// A multi-stop gradient for table effects.
154#[derive(Clone, Debug, PartialEq)]
155pub struct Gradient {
156    stops: Vec<(f32, PackedRgba)>,
157}
158
159impl Gradient {
160    /// Create a new gradient with stops in the range [0, 1].
161    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    /// Access the gradient stops (sorted by position).
168    #[must_use]
169    pub fn stops(&self) -> &[(f32, PackedRgba)] {
170        &self.stops
171    }
172
173    /// Sample the gradient at a normalized position in [0, 1].
174    #[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/// Effect definitions applied to table styles.
208#[derive(Clone, Debug)]
209pub enum TableEffect {
210    /// Pulse between two foreground/background colors.
211    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    /// Breathing glow that brightens/dims around a base color.
220    BreathingGlow {
221        fg: PackedRgba,
222        bg: PackedRgba,
223        intensity: f32,
224        speed: f32,
225        phase_offset: f32,
226        asymmetry: f32,
227    },
228    /// Sweep a multi-stop gradient across the target.
229    GradientSweep {
230        gradient: Gradient,
231        speed: f32,
232        phase_offset: f32,
233    },
234}
235
236/// How effect colors blend with the base style.
237#[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/// Mask for which style channels effects are allowed to override.
248#[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    /// Mask that allows only foreground and background changes.
259    #[must_use]
260    pub const fn fg_bg() -> Self {
261        Self {
262            fg: true,
263            bg: true,
264            attrs: false,
265        }
266    }
267
268    /// Mask that allows all channels.
269    #[must_use]
270    pub const fn all() -> Self {
271        Self {
272            fg: true,
273            bg: true,
274            attrs: true,
275        }
276    }
277
278    /// Mask that blocks all channels.
279    #[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/// A single effect rule applied to a table target.
296#[derive(Clone, Debug)]
297pub struct TableEffectRule {
298    /// Target selection (section/row/column/range).
299    pub target: TableEffectTarget,
300    /// Effect definition to apply.
301    pub effect: TableEffect,
302    /// Rule priority (higher applies later).
303    pub priority: u8,
304    /// Blend mode for effect vs base style.
305    pub blend_mode: BlendMode,
306    /// Mask of style channels the effect can override.
307    pub style_mask: StyleMask,
308}
309
310impl TableEffectRule {
311    /// Create a new effect rule with default blending and masking.
312    #[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    /// Set rule priority (higher applies later).
324    #[must_use]
325    pub fn priority(mut self, priority: u8) -> Self {
326        self.priority = priority;
327        self
328    }
329
330    /// Set blend mode.
331    #[must_use]
332    pub fn blend_mode(mut self, blend_mode: BlendMode) -> Self {
333        self.blend_mode = blend_mode;
334        self
335    }
336
337    /// Set style mask.
338    #[must_use]
339    pub fn style_mask(mut self, style_mask: StyleMask) -> Self {
340        self.style_mask = style_mask;
341        self
342    }
343}
344
345/// Resolve table effects for a given scope and phase.
346///
347/// The resolver is designed to run once per row/column/section (not per cell).
348pub struct TableEffectResolver<'a> {
349    theme: &'a TableTheme,
350}
351
352impl<'a> TableEffectResolver<'a> {
353    /// Create a resolver for a given theme.
354    #[must_use]
355    pub const fn new(theme: &'a TableTheme) -> Self {
356        Self { theme }
357    }
358
359    /// Resolve effects for a specific scope at the provided phase.
360    #[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/// Shared theme for all table render paths.
367///
368/// This controls base styles (border/header/rows), spacing, and optional
369/// effect rules that can animate or accent specific rows/columns.
370///
371/// Determinism guidance: always supply an explicit phase from the caller
372/// (e.g., tick count or frame index). Avoid implicit clocks inside themes.
373///
374/// # Examples
375///
376/// Apply a preset and add an animated row highlight:
377///
378/// ```rust,no_run
379/// use ftui_style::{
380///     TableEffect, TableEffectRule, TableEffectScope, TableEffectTarget, TableSection, TableTheme,
381///     Style,
382/// };
383/// use ftui_render::cell::PackedRgba;
384///
385/// let theme = TableTheme::aurora().with_effect(TableEffectRule::new(
386///     TableEffectTarget::Row(0),
387///     TableEffect::Pulse {
388///         fg_a: PackedRgba::rgb(240, 245, 255),
389///         fg_b: PackedRgba::rgb(255, 255, 255),
390///         bg_a: PackedRgba::rgb(28, 36, 54),
391///         bg_b: PackedRgba::rgb(60, 90, 140),
392///         speed: 1.0,
393///         phase_offset: 0.0,
394///     },
395/// ));
396///
397/// let resolver = theme.effect_resolver();
398/// let phase = 0.25; // caller-supplied (e.g., tick * 0.02)
399/// let scope = TableEffectScope::row(TableSection::Body, 0);
400/// let _animated = resolver.resolve(theme.row, scope, phase);
401/// ```
402///
403/// Override a preset for custom header + zebra rows:
404///
405/// ```rust,no_run
406/// use ftui_style::{TableTheme, Style};
407/// use ftui_render::cell::PackedRgba;
408///
409/// let theme = TableTheme::terminal_classic()
410///     .with_header(Style::new().fg(PackedRgba::rgb(240, 240, 240)).bold())
411///     .with_row_alt(Style::new().bg(PackedRgba::rgb(20, 20, 20)))
412///     .with_divider(Style::new().fg(PackedRgba::rgb(60, 60, 60)))
413///     .with_padding(1)
414///     .with_column_gap(2);
415/// ```
416#[derive(Clone, Debug)]
417pub struct TableTheme {
418    /// Border style (table outline).
419    pub border: Style,
420    /// Header row style.
421    pub header: Style,
422    /// Base body row style.
423    pub row: Style,
424    /// Alternate row style for zebra striping.
425    pub row_alt: Style,
426    /// Selected row style.
427    pub row_selected: Style,
428    /// Hover row style.
429    pub row_hover: Style,
430    /// Divider/column separator style.
431    pub divider: Style,
432    /// Cell padding inside each column (in cells).
433    pub padding: u8,
434    /// Gap between columns (in cells).
435    pub column_gap: u8,
436    /// Row height in terminal lines.
437    pub row_height: u8,
438    /// Effect rules resolved per row/column/section.
439    pub effects: Vec<TableEffectRule>,
440    /// Optional preset identifier for diagnostics.
441    pub preset_id: Option<TablePresetId>,
442}
443
444/// Diagnostics payload for TableTheme instrumentation.
445#[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/// Serializable spec for exporting/importing table themes.
457///
458/// This is a pure data representation (no rendering logic) that preserves
459/// the full TableTheme surface, including effects.
460///
461/// Forward-compatibility notes:
462/// - Unknown fields are rejected when `serde` is enabled (strict schema).
463/// - New fields should be optional with safe defaults to keep older exports valid.
464#[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    /// Schema version for forward-compatible parsing.
469    pub version: u8,
470    /// Optional human-readable name.
471    pub name: Option<String>,
472    /// Original preset identifier, if derived from a preset.
473    pub preset_id: Option<TablePresetId>,
474    /// Layout parameters.
475    pub padding: u8,
476    pub column_gap: u8,
477    pub row_height: u8,
478    /// Style buckets.
479    pub styles: TableThemeStyleSpec,
480    /// Effects applied to the theme.
481    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
604/// Schema version for TableThemeSpec.
605pub 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    /// Create a spec snapshot from a TableTheme.
645    #[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    /// Convert this spec into a TableTheme.
672    #[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    /// Validate spec ranges and sizes for safe import.
695    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    /// Create a resolver that applies this theme's effects.
1164    #[must_use]
1165    pub const fn effect_resolver(&self) -> TableEffectResolver<'_> {
1166        TableEffectResolver::new(self)
1167    }
1168
1169    /// Build a theme from a preset identifier.
1170    #[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    /// Set the border style.
1186    #[must_use]
1187    pub fn with_border(mut self, border: Style) -> Self {
1188        self.border = border;
1189        self
1190    }
1191
1192    /// Set the header style.
1193    #[must_use]
1194    pub fn with_header(mut self, header: Style) -> Self {
1195        self.header = header;
1196        self
1197    }
1198
1199    /// Set the base row style.
1200    #[must_use]
1201    pub fn with_row(mut self, row: Style) -> Self {
1202        self.row = row;
1203        self
1204    }
1205
1206    /// Set the alternate row style.
1207    #[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    /// Set the selected row style.
1214    #[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    /// Set the hover row style.
1221    #[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    /// Set the divider style.
1228    #[must_use]
1229    pub fn with_divider(mut self, divider: Style) -> Self {
1230        self.divider = divider;
1231        self
1232    }
1233
1234    /// Set table padding (cells inset).
1235    #[must_use]
1236    pub fn with_padding(mut self, padding: u8) -> Self {
1237        self.padding = padding;
1238        self
1239    }
1240
1241    /// Set column gap in cells.
1242    #[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    /// Set row height in lines.
1249    #[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    /// Replace effect rules.
1256    #[must_use]
1257    pub fn with_effects(mut self, effects: Vec<TableEffectRule>) -> Self {
1258        self.effects = effects;
1259        self
1260    }
1261
1262    /// Append a single effect rule.
1263    #[must_use]
1264    pub fn with_effect(mut self, effect: TableEffectRule) -> Self {
1265        self.effects.push(effect);
1266        self
1267    }
1268
1269    /// Remove all effect rules.
1270    #[must_use]
1271    pub fn clear_effects(mut self) -> Self {
1272        self.effects.clear();
1273        self
1274    }
1275
1276    /// Override the preset identifier (used for diagnostics).
1277    #[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    /// Luminous header with cool zebra rows.
1284    #[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    /// Monochrome, maximum legibility at dense data.
1311    #[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    /// Neon accent header with vivid highlights.
1337    #[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    /// Subtle slate tones for neutral dashboards.
1364    #[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    /// Warm, sunlight-forward palette.
1390    #[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    /// Orchard greens with soft depth.
1417    #[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    /// Light, paper-like styling for documentation tables.
1444    #[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    /// Deep, nocturnal palette with high contrast accents.
1473    #[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    /// ANSI-16 baseline with richer palettes on 256/truecolor terminals.
1500    #[must_use]
1501    pub fn terminal_classic() -> Self {
1502        Self::terminal_classic_for(ColorProfile::detect())
1503    }
1504
1505    /// ANSI-16 baseline with richer palettes on 256/truecolor terminals.
1506    #[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    /// Produce a deterministic diagnostics summary for logging or tests.
1549    #[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    /// Stable hash of base styles + layout parameters.
1563    #[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    /// Stable hash of effect rules (target + effect + blend + mask).
1581    #[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// ---------------------------------------------------------------------------
1824// Diagnostics hashing (stable, deterministic)
1825// ---------------------------------------------------------------------------
1826
1827#[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        // pulse_effect(fg, bg) - fg_a=fg_b=first_param, bg_a=bg_b=second_param
2148        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        // pulse_effect(fg, bg) - fg_a=fg_b=first_param, bg_a=bg_b=second_param
2168        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                // ANSI16 hover colors are bounded; accept AA large-text threshold.
2419                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}