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