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:
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.