Table
The Table widget renders structured rows and columns with row or cell
selection, header rows, and a column-width model that falls out of the
same constraint system used by layout. It is one of the few widgets that
genuinely needs StatefulWidget — scroll offset and cursor both live on
the caller-owned TableState.
Styling is entirely externalised to TableTheme, a 3.5 K-line theming
engine documented separately on table theme. The
widget itself stays small; the theme is where borders, zebra stripes,
column accents, and focus indicators live.
Source: table.rs:78.
Shape of the API
use ftui_widgets::StatefulWidget;
use ftui_widgets::table::{Table, TableState, Row, Cell};
use ftui_layout::Constraint;
let rows = vec![
Row::new(vec![Cell::from("1"), Cell::from("alpha")]),
Row::new(vec![Cell::from("2"), Cell::from("beta")]),
];
let table = Table::new(rows, &[
Constraint::Length(4), // ID column: fixed 4 cells
Constraint::Min(10), // Name column: fills remaining space
])
.header(Row::new(vec![Cell::from("id"), Cell::from("name")]))
.highlight_symbol("> ");
let mut state = TableState::default();
StatefulWidget::render(&table, area, frame, &mut state);Three things to notice:
- Constraints per column. Same
Constraintenum as layout —Length,Percentage,Min,Max,Ratio,Fill. - Optional header row. Headers render with their own style and are excluded from selection.
TableStateis caller-owned.selectedandoffsetlive there, not on the table.
TableState
Source: table.rs:360.
pub struct TableState {
pub selected: Option<usize>, // selected row index
pub offset: usize, // scroll offset (first visible row)
// … cached column widths, column cursor, etc.
}Update mutates selected; render clamps offset so selected stays
visible. Reset either by setting fields manually or by calling
TableState::default().
Column constraints
Column widths are computed by the same solver layout uses. A few common patterns:
| Goal | Constraints |
|---|---|
| Two equal columns | [Ratio(1,2), Ratio(1,2)] |
| ID column + flex name | [Length(4), Min(10)] |
| Three columns summing to 100% | [Percentage(30), Percentage(40), Percentage(30)] |
| Cap a column at 20 cells | [Min(5), Max(20)] |
Given a 60-cell viewport and [Length(4), Min(10), Length(8)] the solver
assigns 4, 48, 8. Given [Length(4), Length(4), Length(4)] with viewport
width 60, only 12 cells are used; the remainder stays blank.
See flex and grid for the full constraint vocabulary — it is identical to layout’s.
Row selection vs cell selection
By default the table highlights a whole row. You can opt into cell
selection — a column cursor that moves horizontally with h / l or
arrow keys — via:
let table = Table::new(rows, &constraints)
.cell_selection(true);The TableState then tracks selected_column in addition to
selected_row. Navigation inside update() is your responsibility; the
widget clamps to valid positions but does not bind keys.
Theming via TableTheme
The widget is styling-agnostic. All chrome — borders between rows, zebra
stripe alternation, header emphasis, focused-row background, scrollbar
appearance — is driven by a TableTheme you hand in:
use ftui_extras::theme::TableTheme;
let theme = TableTheme::default()
.with_zebra(true)
.with_header_style(Style::default().bold());
let table = Table::new(rows, &constraints).theme(theme);The theme’s 3.5 K lines cover 40+ named styles, border families, and per-column accent slots. See table theme for the full enumeration.
Because theming is external, the same Table construction renders
identically across dark and light themes, accessibility modes, and
non-color profiles. The widget has no hard-coded style.
Headers and pinned columns
header(row) sets a sticky header that does not scroll with the body.
Rendering logic:
- Header occupies row 0 of the table area.
- Body starts at row 1.
- Scroll offset only applies to body rows.
Pinned left-side columns (frozen during horizontal scroll) are a planned
extension; the constraint slots already encode the semantics but the
presenter side isn’t wired yet. Track this via the
repro_table_header_width.rs
regression test.
Composition with other widgets
Tables nest naturally inside Block for borders, inside Panel for a
titlebar + shadow, or inside a pane from ftui-layout
for resizable splits. Because selection is in TableState, restoring a
table’s view after hiding / re-showing it is a simple “keep the state”.
Worked example: file entries with sort
use ftui_widgets::{
StatefulWidget,
block::{Block, Borders},
table::{Table, TableState, Row, Cell},
};
use ftui_layout::Constraint;
pub struct Model {
entries: Vec<Entry>,
table_state: TableState,
sort_column: usize,
sort_desc: bool,
}
impl Model {
fn sorted(&self) -> Vec<&Entry> {
let mut v: Vec<_> = self.entries.iter().collect();
v.sort_by(|a, b| {
let ord = match self.sort_column {
0 => a.name.cmp(&b.name),
1 => a.size.cmp(&b.size),
_ => std::cmp::Ordering::Equal,
};
if self.sort_desc { ord.reverse() } else { ord }
});
v
}
// `Model::view` is `&self`; this helper uses `&mut self` so
// `StatefulWidget::render` can borrow `&mut self.table_state`.
// Wrap `table_state` in `RefCell<TableState>` to call from a real
// `view`, or invoke this as a helper from a thin `view` shim.
fn render_files(&mut self, frame: &mut ftui_render::frame::Frame) {
let area = frame.buffer.bounds();
let rows: Vec<Row> = self.sorted().into_iter()
.map(|e| Row::new(vec![
Cell::from(e.name.as_str()),
Cell::from(format!("{}", e.size)),
]))
.collect();
let table = Table::new(rows, &[
Constraint::Min(20),
Constraint::Length(12),
])
.block(Block::default().borders(Borders::ALL).title(" Files "))
.header(Row::new(vec![Cell::from("name"), Cell::from("size")]))
.highlight_symbol("> ");
StatefulWidget::render(
&table, area, frame, &mut self.table_state,
);
}
}Pitfalls
- Mutating the rows vec between
update()andview().TableStatestores indices; growing or shrinking the data can leaveselectedpointing into thin air. Clamp after any mutation. - Wrong constraints sum. Constraints don’t have to sum to anything in
particular, but
Length(N)values that total more than the viewport will be clipped — leading to a table with one invisible column. - Calling
cell_selection(true)without binding horizontal keys. The column cursor doesn’t move on its own; Table provides the storage, yourupdate()moves it. - Expecting zebra stripes without a theme. Default
TableThemeleaves zebra off. Enable it explicitly if you want it.
Where next
The 3.5 K-line theming engine: 40+ named styles, column accents, border families.
TableThemeWhen a Table isn’t enough — 100K+ rows with variable heights.
The Constraint vocabulary shared with layout.
When your data is hierarchical, not tabular.
TreeHow this piece fits in widgets.
Widgets overview