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