Skip to Content

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:

  1. Constraints per column. Same Constraint enum as layout — Length, Percentage, Min, Max, Ratio, Fill.
  2. Optional header row. Headers render with their own style and are excluded from selection.
  3. TableState is caller-owned. selected and offset live 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:

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

  1. Header occupies row 0 of the table area.
  2. Body starts at row 1.
  3. 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() and view(). TableState stores indices; growing or shrinking the data can leave selected pointing 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, your update() moves it.
  • Expecting zebra stripes without a theme. Default TableTheme leaves zebra off. Enable it explicitly if you want it.

Where next