Skip to Content
ftui-styleTable theme

Table Theme

TableTheme is the purpose-built theming container for tabular data. It is three and a half thousand lines of code in crates/ftui-style/src/table_theme.rs because tables have more structure — headers, bodies, footers, rows, columns, cells, borders, selections, hover — than most UI surfaces, and it pays off to express that structure as first-class types rather than bake it into the widget.

What makes tables different

A row of text has one style. A table cell can participate in:

  • its section (Header / Body / Footer) style,
  • its row style (alternating zebra stripes, selected row),
  • its column emphasis,
  • a hover overlay (with CUSUM stabilization so it doesn’t flicker),
  • an effect-specific gradient or solid color,
  • its border between cells,
  • its padding inside the cell.

TableTheme lets you declare all of these independently and composes them at render time.

The four-tier overlay model

┌──────────────────────────────────────────────┐ │ 4. Interactive overlays (hover / selection) │ runtime ├──────────────────────────────────────────────┤ │ 3. Effect rules (gradients, highlights) │ per-theme ├──────────────────────────────────────────────┤ │ 2. Section / row / column defaults │ per-theme ├──────────────────────────────────────────────┤ │ 1. Preset base (Aurora, Graphite, Neon, …) │ the theme └──────────────────────────────────────────────┘

Higher tiers override lower tiers via the cascade.

Presets

Nine ready-to-go themes are exposed as TablePresetId:

pub enum TablePresetId { Aurora, // luminous header + cool zebra Graphite, // high-contrast for dense data Neon, // neon accents on dark Slate, // muted tones, soft dividers Solar, // warm palette, bright header Orchard, // greens + warm highlights Paper, // light theme, crisp borders Midnight, // dark terminal palette TerminalClassic, // ANSI-friendly }

Each preset is a ready-made TableTheme:

let theme = TableTheme::preset(TablePresetId::Aurora);

Semantic sections

Every cell belongs to exactly one section:

pub enum TableSection { Header, Body, Footer }

Builders set the section style directly:

TableTheme::preset(TablePresetId::Graphite) .with_header(Style::new().bold()) .with_row(Style::new()) // body default .with_row_alt(Style::new().dim()) // zebra .with_row_selected(Style::new().reverse()) .with_row_hover(Style::new().underline());

Effect targets

Finer-grained rules attach to TableEffectTarget:

pub enum TableEffectTarget { Section(TableSection), Row(usize), RowRange { start: usize, end: usize }, Column(usize), ColumnRange { start: usize, end: usize }, AllRows, AllCells, }

A cell’s rendered appearance is the composition of every rule whose target matches its TableEffectScope { section, row, column }.

TableEffect — what an effect actually is

pub enum TableEffect { SolidColor(PackedRgba), GradientSweep { gradient: Gradient, /* … */ }, /* more variants for striping, pulses, etc. */ }

And a TableEffectRule binds a target to an effect:

pub struct TableEffectRule { pub target: TableEffectTarget, pub effect: TableEffect, pub priority: u8, pub blend_mode: BlendMode, pub style_mask: StyleMask, }

BlendMode — how overlapping effects combine

pub enum BlendMode { Normal, Add, Multiply, Screen, Overlay, /* … */ }

Same semantics as compositing in a 2D graphics API. Normal means “top wins”; Multiply darkens; Screen lightens; Overlay is the contrast-preserving mixing common in photo editing.

Gradients — for sweeps across a range

pub struct Gradient { stops: Vec<(f32, PackedRgba)>, // position in [0, 1] } impl Gradient { pub fn new(stops: Vec<(f32, PackedRgba)>) -> Self; pub fn sample(&self, t: f32) -> PackedRgba; }

A gradient sweep across columns makes heatmap-style coloring trivial:

heatmap_cols.rs
use ftui_style::table_theme::{ Gradient, TableEffect, TableEffectRule, TableEffectTarget, TableTheme, TablePresetId, }; use ftui_render::cell::PackedRgba; let heatmap = Gradient::new(vec![ (0.0, PackedRgba::rgb(20, 60, 200)), // cool (0.5, PackedRgba::rgb(200, 200, 200)), // neutral (1.0, PackedRgba::rgb(200, 40, 40)), // hot ]); let theme = TableTheme::preset(TablePresetId::Paper).with_effect( TableEffectRule::new( TableEffectTarget::AllRows, TableEffect::GradientSweep { gradient: heatmap, /* axis… */ }, ) .priority(10), );

Resolution — TableEffectResolver

At render time a TableEffectResolver reduces all matching rules to a final Style for each cell:

impl<'a> TableEffectResolver<'a> { pub fn resolve( &self, base: Style, scope: TableEffectScope, phase: f32, // animation phase, for pulsing effects ) -> Style; }

phase lets animated effects (pulses, moving gradients) share a global clock with the runtime without each rule maintaining its own state.

Hover stabilization — CUSUM

Pointer jitter would make hover highlights flicker on and off. The hover overlay is stabilized with a CUSUM (cumulative-sum) test: a move only flips the hover state once the accumulated evidence crosses a threshold. See the intelligence/cusum page for the math; the point for theme authors is that you don’t have to wire the anti-flicker yourself — the resolver handles it.

Borders — nine variants

Cell borders pick from nine styles (Solid, Dashed, Double, Rounded, Bold, Thin, None, and two MDC-style variants). Padding, column gap, row height, and divider style are all with_* builder methods on TableTheme.

Spec layer — serializable themes

TableThemeSpec / TableThemeStyleSpec are the serde-friendly representations. Save a theme to JSON, ship it with your app, load it back:

let spec = TableThemeSpec::from_theme(&theme); let json = serde_json::to_string(&spec)?; /* … later … */ let back = serde_json::from_str::<TableThemeSpec>(&json)?; back.validate()?; // structural errors before use let theme = back.into_theme();

validate returns TableThemeSpecError with a path string ("rules[3].gradient.stops[1].pos") when structural issues exist. The error surface is designed for “user authored a broken theme JSON” — human-readable diagnostics, no surprise panics.

Pitfalls

Priority is a u8. 0 is lowest; 255 is highest. Collisions at the same priority fall back to declaration order. If two rules fight for the same cell at the same priority, the first registered wins, not the last.

Gradients are on the color axis only. They don’t modulate attributes (bold, underline). If you want “gradient + bold,” declare them as two rules.

Don’t bypass the resolver. Direct Style composition on the widget side ignores the hover stabilizer, which makes jittery hover highlights. Call resolver.resolve(...) once per cell per frame.

Where to go next