Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TableView and TreeTableView

Two tabular widgets for Teksilo: a flat TableView<T> over any ListDataSource<Item = T> and a hierarchical TreeTableView<T> over a SortFilterTreeModel<T> (from_projection), a plain TreeModel<T> (new), or any TreeDataSource<Item = T> (from_source / from_source_keyed). They share the same column model, header strip, drag/resize/reorder, filter popover, keyboard map, and accessibility wrappers; only the body pane differs. The header strip is a widget of its own, TableHeader, which also works over rows an application lays out itself.

This page is the reference for the public surface and the design contracts you can rely on.


At a glance

#![allow(unused)]
fn main() {
use teksilo::data::{SelectionMode, SelectionModel, SortDirection, SortFilterListModel};
use teksilo::prelude::*;
use teksilo::widgets::{
    Column, ColumnWidth, GridLines, TableAlignment as Alignment,
    TableSelectionMode, TableView, TextWidget,
};

let model      = ListModel::from_vec(rows());
let selection  = SelectionModel::new(SelectionMode::Multi);
let proxy      = SortFilterListModel::new(model)
    .with_comparator("name", |a, b| a.name.cmp(&b.name))
    .with_predicate("name", |t| {
        let needle = t.to_lowercase();
        Box::new(move |row| row.name.to_lowercase().contains(&needle))
    });

let table = TableView::from_source(proxy.clone())
    .add_column(
        Column::new("name", lit!("Name"), |row, _| {
            Box::new(TextWidget::new(lit!(row.name.clone())))
        })
        .width(ColumnWidth::Flex(2.0))
        .sortable(true)
        .filterable(true),
    )
    .row_height(28.0)
    .alternating_rows(true)
    .grid_lines(GridLines::Horizontal)
    .selection_mode(TableSelectionMode::MultiRow)
    .selection(selection.clone());

// One-shot wiring: the proxy now consumes the table's signals.
proxy.sort_signal(table.sort_signal().clone());
proxy.filters_signal(table.filters_signal().clone());
table.set_sort(Some("name"), SortDirection::Ascending);
}

TreeTableView is identical in shape; built from a SortFilterTreeModel<T> (from_projection) it adds a tree_column(id) (default: the first column) plus an optional filter_mode(...):

#![allow(unused)]
fn main() {
let proxy = SortFilterTreeModel::new(model)
    .filter_mode(TreeFilterMode::KeepAncestors)
    .with_predicate("name", /* … */);

let tree = TreeTableView::from_projection(proxy.clone())
    .add_column(/* tree column with the twist arrow */ name_col)
    .add_column(size_col)
    .tree_column("name")
    .selection_mode(TableSelectionMode::MultiRow);
}

Row heights

Three mutually exclusive modes (the last builder call wins), identical on TableView and TreeTableView:

#![allow(unused)]
fn main() {
.row_height(28.0)                 // uniform, the default fast path
.row_height_fn(|row| { /* … */ }) // exact per-row callback
.auto_row_height(30.0)            // measured, 30 px estimate seed
}
  • Uniform (row_height), every row is the same height. Pure arithmetic, no allocation; this is the historical behavior and stays the default, at the active TableStyle's row_height (28 px under IntUI at Compact, raised to the density's target size above it; 24 px under macOS and 40 px under Fluent at Compact). A row_height(..) the app sets wins over the style.
  • Exact (row_height_fn), a pure callback fn(visible_index) -> f32 seeds a prefix-sum offset table (O(log n) row↔y lookups). No measurement pass, exact scrollbar, zero jitter. The callback is re-swept from the first changed index on every model change, so it must be deterministic for the data it indexes.
  • Auto-measure (auto_row_height(estimate)), each realized row reports the height of its tallest cell, measured at the cell's column width (height-for-width, wrapped text just works). Unrealized rows assume the estimate. Two consequences:
    • Scroll anchoring: when a correction shifts content above the viewport top, scroll_y is adjusted in the same pass so on-screen content doesn't jump (one-frame latency).
    • Scrollbar settle: the root computes scrollbar totals before the body pane measures, so the thumb geometry settles one frame after a measurement change. A realization re-check guarantees rows always tile the full viewport even when the estimate was far too large.

PageUp / PageDown page by visual distance (the row one viewport above/below the current row's top), not by a fixed rows-per-page count.

Invalidation: which heights survive a model change

Measured/seeded heights are keyed by visible index, so the question on every change is "from which row on are they stale?". The projection layers answer it: SortFilterListModel, SortFilterTreeModel, TreeSlice, and TreeDataSlice expose first_changed_index() (see data-models.md), and the tables consume it automatically:

  • appending rows keeps every measured height (divergence = old length), even though SortFilterListModel notifies with a blanket Reset;
  • expanding/collapsing a TreeTableView node keeps the heights of all rows above the toggle, no scroll jump;
  • a sort flip invalidates from the first reordered row.

Which row a y coordinate resolves to

row_height_fn / item_height_fn / item_height are public callbacks with no floor above 0.0, and spacing defaults to 0.0, a zero-height row is an ordinary, supported configuration (a filtered-to-nothing group header, a collapsed detail row), not a corner case to route around. The shared PrefixSumOffsets table underlies both the exact and auto-measure modes and its row_at(y) is the single place that resolves a pixel coordinate to a row index, it's what both a click and a drag-drop hover call, so it is also the raw drop-target identity in TreeView/TreeTableView DnD and the hit-tested tile in GridView (see drag-and-drop.md §9).

A fully degenerate table, every row height and the spacing are zero, the fully-collapsed-or-filtered-to-nothing case, used to disagree with RowMetrics::uniform's equivalent geometry: Uniform::row_at short-circuits on step <= 0.0 and answers row 0, while the offset table ties every entry and partition_point resolved to the last tied index, answering the final row instead. PrefixSumOffsets::row_at now checks the same degeneracy structurally (every offset equal and the last row's own height is zero) and answers 0, so a click and a drop at the same y agree regardless of which row-height mode the view uses.

That check is deliberately narrower than "resolve every tie to the first index." A partially degenerate table, a run of zero-height rows between two real ones, must keep the last-tied answer: heights [50, 0, 50] give offsets [0, 50, 50, 100], and at y = 50 the right row is 2, the real row that actually starts there, not the invisible row 1. Answering with a zero-height row there would silently retarget a click or a drop onto a row nothing is drawn for.


Scrolling to a row

To bring a row into view from code, take the view's handle while building the tree, before the view is added, and keep it wherever the code that scrolls lives (a view-model, a search box's handler):

#![allow(unused)]
fn main() {
let table = TableView::new(model.clone()).add_column(name_col);
let rows = table.handle();          // TableViewHandle; TreeTableViewHandle
let table_id = ctx.add(table);

// Later, from any handler, even in the same turn as a model change:
model.insert(0, fresh_row);
rows.ensure_row_visible(found);     // the minimum scroll, none if visible
rows.scroll_to_row(found);          // or: align the row to the top
}

The handle is a cheap Clone, may be taken and used before the view is mounted, and survives the view's rebuilds. It never computes an offset when it is called: it records the request and asks for a layout pass, and the body pane applies it there, after the auto-measure pass and the scroll anchoring have settled the row offsets. Three consequences:

  • a request made before the first layout is applied at that layout;
  • a model change made in the same turn (rows inserted above, a TreeTableView node expanded) is accounted for, including the larger scroll range it makes;
  • with auto_row_height, the target's own height is a guess until the row is realized, so the request is applied again on the pass that measures it. The row lands where it should within a few frames, not on the first.

A later request replaces a pending one. A request on a view with no rows is dropped; a row past the end reveals the last row. TreeTableView rows are flat visible indices, and with pinned_ancestors the row stops below its pinned ancestors, as every other reveal does.

The views also have scroll_to_row / ensure_row_visible as methods on the builder value. Those compute the offset when called, against the scroll range and viewport of the last layout pass: before the first layout that range is zero, and after a same-turn model change it still describes the old rows. They are kept for code that already holds the view and has laid it out; prefer the handle.


Column model

A column is a generic descriptor over the row type:

#![allow(unused)]
fn main() {
pub struct Column<T: 'static> { /* … */ }

Column::new("id", lit!("ID"), |row, ctx| Box::new(TextWidget::new(lit!(row.id.to_string()))))
    .width(ColumnWidth::Fixed(64.0))   // Fixed | Flex(factor) | Auto
    .min_width(40.0)
    .max_width(200.0)
    .alignment(Alignment::Trailing)    // Leading | Center | Trailing
    .sortable(true)
    .filterable(true)                  // exposes the filter popover affordance
    .resizable(true)                   // default true
    .reorderable(true)                 // COLUMN drag-reorder; default true
    .pinned(PinnedSide::Leading)       // Leading | None | Trailing
    .truncation(TruncationPolicy::Ellipsis) // Ellipsis | None | Fade
    .editable(true);                   // cells may open an editor (see Editing)
}

Column ids are the persistence key for sort, filter, width, and order signals, keep them stable across releases.

CellContext passed to the cell delegate carries:

FieldMeaning
row_indexvisible row (post sort/filter)
col_idthe column's stable id
col_indexdisplay position (0-based, post pin + reorder)
is_selectedtrue when this row (or cell, in cell-mode) is selected
is_focusedtrue when this cell carries the keyboard focus
is_hoveredtrue when the pointer is over this cell
is_editingtrue when editing_cell_signal == Some((row, col_index))
depthSome(level) in TreeTableView, None in TableView
is_tree_columntrue on the column hosting the twist arrow

Stretch the last column

#![allow(unused)]
fn main() {
table.stretch_last_column(true)   // TableView and TreeTableView; default off
}

Qt's stretchLastSection, NSTableView's lastColumnOnlyAutoresizingStyle: the last column in display order takes whatever width the other columns leave, so a pixel-sized table (Fixed / Auto columns, or widths the user has dragged) never ends in a bare strip at its trailing edge. It is positional, not a property of a column, after a reorder the new last column stretches and the previous one goes back to its own width.

While a column stretches:

  • its declared width is the floor it grows from, capped by its max_width;
  • its own entry in column_widths_signal is ignored, and its trailing grip is disabled (no AccessKit Increment/Decrement either), because any size the user gave it the stretch would take straight back. The grip on its leading edge still resizes its predecessor, and resizing any other column reflows it;
  • once the other columns exceed the viewport there is nothing left to stretch into: it sits at its own width and the pane scrolls, as in Qt.

Flex columns already share every spare pixel among themselves, so a table of Flex columns looks the same with or without it.

Sort / filter / widths / order: the signal contract

Both widgets publish six reactive signals. Mutating any of them triggers the right rebuild level (no full layout when scrolling, no rebuild when only the focus ring moves, etc.).

SignalTypeMutated byPersist via
sort_signalSignal<Option<(String, SortDirection)>>header click cycle, set_sort, clear_sortSettingsFile<T>
filters_signalSignal<HashMap<String, String>>filter popover, set_filter, clear_filtersSettingsFile<T>
column_widths_signalSignal<HashMap<String, f32>>header drag-resize, set_column_widthSettingsFile<T>
column_order_signalSignal<Vec<String>>header drag-reorder, set_column_orderSettingsStore key (e.g. table.order)
column_pinning_signalSignal<HashMap<String, PinnedSide>>drag across pane boundary, set_column_pinningSettingsFile<T>
focused_cell_signalSignal<Option<(usize, usize)>>keyboard nav, set_focused_cell, clear_focused_cell, a selection change in a single-selection table(transient)

Adopting signals the application owns

By default a view creates its sort, width, order and filter signals itself. Hand it signals the application already holds and it adopts them instead, so several views (a library shown four ways), or a view-model, share one state without mirroring it through observers:

#![allow(unused)]
fn main() {
let sort    = Signal::new(None);
let widths  = Signal::new(HashMap::new());
let order   = Signal::new(Vec::new());
let filters = Signal::new(HashMap::new());

let table = TableView::from_source(proxy.clone())
    .add_column(name_col)
    .add_column(size_col)
    .bind_sort(sort.clone())
    .bind_column_widths(widths.clone())
    .bind_column_order(order.clone())
    .bind_filters(filters.clone());
// The projection reads the same two signals the header writes.
proxy.sort_signal(sort.clone());
proxy.filters_signal(filters.clone());
// A second view built with the same four `bind_*` calls follows every edit.
}

TreeTableView has the same four builders, and the getters (sort_signal(), column_widths_signal(), …) return the adopted signal. The contract:

  • Adopting writes nothing. A view never seeds an adopted signal: not when it is built, mounted, laid out or rebuilt. What the signal holds is what the view shows, and an empty map, an empty list or None means the declared defaults. The view writes only for a user gesture (a header click, a resize drag, a reorder drop, the filter popover, an AccessKit increment) or an imperative setter (set_sort, set_column_widths, …).
  • Ids a view does not declare are ignored when read and kept when written. A width, a filter or an order entry for a column the view lacks changes nothing in it; a column with no width entry takes its declared ColumnWidth. A reorder drop moves the dragged column and leaves every other entry where it was, and a resize or a filter edit only touches its own key, so views with different column sets can share one map. The Flex columns before a resized one keep their widths in the view the resize was made in, and only there (see Column resize); a sort on a column a view lacks leaves its rows unsorted, so a TreeTableView still reorders them.
  • No feedback loop. A view answers a change by relaying out or rebuilding, never by writing back, so two views bound to the same signals settle after one pass.
  • Bind before any imperative setter. A setter called earlier in the builder chain wrote the view's own signal, which the bind then replaces.

There is no bind_ builder for the pinning map: a reorder drop that crosses a pane seam pins the column in the view it was dropped on only.

Persistence

Use teksilo-settings to round-trip the layout. A SettingsStore key whose type matches a view's signal can be adopted directly, with nothing to keep in sync:

#![allow(unused)]
fn main() {
const TABLE_ORDER: SettingsKey<Vec<String>> = SettingsKey::new("table.order", Vec::new);

let table = table.bind_column_order(ctx.settings().signal_for(&TABLE_ORDER));
}

State that is not one signal of the view's type (the sort encoded as a string, or a map held in a SettingsFile<T>), and a view that keeps its own signals, need a round trip instead. A typical shape, shown for the order:

#![allow(unused)]
fn main() {
const TABLE_SORT:  SettingsKey<String>      = SettingsKey::new("table.sort", String::new);
const TABLE_ORDER: SettingsKey<Vec<String>> = SettingsKey::new("table.order", Vec::new);

let order = ctx.settings().signal_for(&TABLE_ORDER);   // Signal<Vec<String>>
// Restore before the table is mounted, then keep both directions in sync.
// `TableView` is not `Clone` (it is moved into the tree), so the closures
// hold clones of its signal, which share state with the mounted table.
// Hold the returned `ObserverHandle`s for as long as the table lives,
// dropping one unsubscribes it.
table.set_column_order(order.get());
let table_order = table.column_order_signal().clone();
let restore = order.observe({
    let table_order = table_order.clone();
    move |o| if table_order.get() != *o { table_order.set(o.clone()) }
});
let persist = table_order.observe({
    let order = order.clone();
    move |o| if order.get() != *o { order.set(o.clone()) }
});
// Repeat for sort (encoded as a string).
}

The map-valued signals, widths, filters, pinning, cannot be SettingsStore keys: a HashMap serializes as a TOML table, which the store's nested-key model cannot tell apart from a group of keys, so signal_for panics at registration. Persist them in one app-layout struct through a SettingsFile<T> instead (its writes are synchronous, so pair a widths round trip with ColumnResizePolicy::OnRelease, or write on your own debounce).

The signal API is the persistence boundary on purpose, the widget emits, the application persists. There are no on_*_changed hooks; an observe on the signal is the same thing without the typo surface.

Note the shape: two observers pointing at each other. Signal::set carries no equality check by design, so such a pair is an unbounded mutual recursion unless one edge guards its write, and a ColumnResizePolicy::Live resize writes a width on every pointer move, so the loop would fire on the first tick of the first drag. The table's imperative setters, set_column_widths, set_column_width, set_sort, clear_sort, set_filter, clear_filters, set_column_order, set_column_pinning, are therefore equality-guarded: an unchanged value neither writes nor notifies, which is what makes the round trip settle after one pass. A write straight to the signal (as in the closures above, which cannot reach the moved TableView) or through a transform of your own must guard its own edge the same way.

SortFilterListModel<T> vs raw signals

The minimum the widget needs is the signals above; you can apply sort and filter manually in your own observe on sort_signal / filters_signal. Don't. Use the proxy:

#![allow(unused)]
fn main() {
let proxy = SortFilterListModel::new(model)
    .with_comparator("name", |a, b| a.name.cmp(&b.name))
    .with_predicate("name", |t| { /* … */ });
let table = TableView::from_source(proxy.clone());
proxy.sort_signal(table.sort_signal().clone());
proxy.filters_signal(table.filters_signal().clone());
}

The proxy:

  • maintains a single visible-index map shared between sort and filter,
  • emits DataChange::Reset once per upstream change (one rebuild, not two), except an ItemUpdated that neither changes the row's filter verdict nor moves it past a neighbour, which is forwarded as a scoped ItemUpdated (see below),
  • reports first_changed_index() so measured row heights survive the Reset.

Because the proxy speaks Reset, an index-based SelectionModel behind it is cleared by every sort, filter, or upstream structural change. Where the selection must survive those, use keyed selection (TableView::from_source_keyed with a KeyedSelectionModel; the proxy's key is the row's source index, which no sort/filter reprojection renumbers).

For trees, SortFilterTreeModel<T> plays the same role, plus a TreeFilterMode switch:

ModeBehaviour
HideNonMatchingrows that don't match are hidden, taking their entire subtree with them
KeepAncestorsa match keeps every ancestor visible (file-tree convention; the default)
KeepDescendantsa match keeps its full subtree visible (useful for "find a folder, see what's inside")

TreeTableView::filter_mode(...) forwards to the proxy in place, calling it on the builder mutates the shared Rc<RefCell<…>> even though the method consumes Self. It only has an effect on a view built with from_projection; the other constructors hold no proxy to forward to.

Incremental updates for a single-row edit

A DataChange::ItemUpdated (list) or TreeChange::NodeUpdated (tree) from the upstream model doesn't always force the full filter/sort/flatten pass described above. Both proxies first try a cheap fast path: re-check just the edited row's filter verdict and its rank against its current visible neighbours, instead of re-filtering and re-sorting every row. They fall back to the full rebuild whenever the row enters/leaves the visible set, or moves past a neighbour, including a neighbour it now ties with. The tie case matters because the full rebuild sorts with Vec::sort_by, which is stable, so it always resolves a tie the same way (source index for the list, original sibling order for the tree); leaving an edited row in its old slot on a tie would disagree with that reprojection, and the row would visibly jump the next time an unrelated mutation forced a full rebuild.

The two proxies pay a different price for that correctness. SortFilterListModel compares source indices directly, so it still takes the fast path for a tie that's already in stable order. SortFilterTreeModel would have to walk tree.children(parent) to recover a tied node's sibling index, so it bails to a full reprojection on any tie rather than pay that cost on every update. Sorting a large tree on a low-cardinality column (a status enum, a boolean) therefore falls back to a full reprojection more often than the equivalent flat list would, worth knowing when picking what column to sort on.


The header on its own: TableHeader

Both views draw their column header with TableHeader, and an application can use it by itself, over rows it lays out itself: a section list whose group rows span every column, an album card beside its tracks. It has the same resize grips, sort cycle, drag-reorder, pinned panes, filter popover and Role::ColumnHeader nodes as the views, because the views' header is this widget.

#![allow(unused)]
fn main() {
let widths = Signal::new(HashMap::new());
let sort   = Signal::new(None);
let order  = Signal::new(Vec::new());
let scroll = Signal::new(0.0_f32);   // the horizontal offset of the rows below

let header = TableHeader::new(vec![
    ColumnSpec::new("title", lit!("Title")).sortable(true),
    ColumnSpec::new("length", lit!("Length"))
        .width(ColumnWidth::Fixed(80.0))
        .sortable(true),
])
.widths(widths.clone())
.sort(sort.clone())
.order(order.clone())
.scroll_x(scroll.clone());
// What each column was laid out at: line the rows' cells up with this.
let columns = header.resolved_widths_signal().clone();
}

A column is a ColumnSpec: a Column<T> without its cell delegate. It has the same builders and defaults for the id, title, width / min_width / max_width, alignment, sortable, filterable, resizable, reorderable and pinned, and a Column<T> carries one and forwards those builders to it.

State. widths, sort, order and scroll_x, plus pinning and filters, adopt application signals under the same contract as the views' bind_* builders (above). A signal the header is not handed is its own, read back with widths_signal(), sort_signal(), order_signal(), pinning_signal(), filters_signal() or scroll_x_signal(). Give a TableView the same signals and the header and the table follow each other. resize_policy and stretch_last_column are the views' knobs of the same name.

Layout. The width map holds overrides, not the layout. What the header laid each column out at, after the overrides, the declared widths, the Flex share and the min / max clamps, is resolved_widths_signal(): a Signal<Vec<(String, f32)>> of (column id, width) per displayed column, in display order, written when the header is placed in a layout pass and only when it changed. Leading-pinned columns come first and stay put under scroll_x; trailing-pinned ones come last.

Rows follow it one layout pass late, unless they read it in their own place_children and are laid out after the header (below it in the same stack). Only the header's bounds settle the widths, so a row that sizes itself from them in layout_response, a FixedSize bound to a width derived from the signal for instance, has already been measured when they are written, and is relaid out on the next pass. During a live resize drag such a row trails the header by a frame, and a headless test needs a second layout() before it lines up.

On its own the header resolves its widths against its own bounds, clips its cells to them, takes the active TableStyle's header_height when its parent leaves the height free, and paints the ColumnResizePolicy::OnRelease guide line within the strip only.

Accessibility. The strip is the Role::Row with row index 1 and its cells are the Role::ColumnHeader nodes described below. It does not announce a table: place it inside the container that announces the rows under it as a table or a grid.

Inside a view the header is hosted: the view resolves the widths and the display order (its body needs both before the header is placed), rebuilds the header along with itself, holds the resize-drag state, which a relayout keeps, and paints the OnRelease guide across its rows. A rebuild abandons a resize in flight, hosted or not: it re-creates the header cells, and the pointer capture the drag depends on goes with them.


The filter popover

When Column::filterable(true), the header cell paints a small funnel glyph at the trailing end (just before the resize zone). Tapping it opens a Popover anchored to the glyph; the popover content is a TextInput with a trailing clear IconButton that write the filters_signal[col_id] slot.

  • The popover dismisses on Escape or click-outside (default DismissBehavior::EscapeOrClickOutside).
  • The typed term is applied on Enter, not per keystroke (applying each character would re-query the source and rebuild the table, tearing down the popover mid-word); the clear button empties the field and applies at once. Empty text removes the column's entry from the map; a non-empty string inserts/replaces it.
  • The glyph tints TextRole::Accent when the column has an active filter and TextRole::Secondary otherwise.

Callers that already use SortFilterListModel<T> / SortFilterTreeModel<T> get filtered output for free, filters_signal re-projects the visible list whenever the popover mutates the map.

The editor inside the popover is the stock TextInput, so it has the full single-line editing surface (IME, clipboard, caret navigation); the filter UI is available in any TableView/TreeTableView build.

The header's pointer handler reserves a filter zone at the trailing edge (resize handle + filter glyph + a small padding tolerance) so that PointerDown over the popover trigger reaches the trigger instead of being eaten by the sort-cycle handler. Outside that zone, a click on the header label still cycles the sort as before.


Selection

TableSelectionMode picks the model:

ModeBacking modelNotes
None,clicks just move focus
SingleRowteksilo_data::SelectionModelreplaces; modifier keys ignored
MultiRow (default)teksilo_data::SelectionModel with SelectionMode::MultiCtrl-click toggles, Shift-click extends, Shift+Arrow extends
SingleCellCellSelectionModelExcel-style; one (row,col) at a time
MultiCellCellSelectionModelrectangular extension via Shift+Arrow / Shift+Click

Both selection models auto-adjust on DataChange::ItemsInserted / ItemsRemoved / ItemsMoved, so selection follows fine-grained upstream mutation; a DataChange::Reset, which a SortFilterListModel emits on every sort or filter change, clears an index-based selection. A KeyedSelectionModel (via from_source_keyed) prunes only the keys that disappeared, so it survives sorting and filtering.

TreeTableView accepts both row and cell modes. With a plain SelectionModel, selection is keyed by the flat visible index of the tree projection; expanding/collapsing re-numbers indices, so don't pin such a selection across an expand_all() without a re-mapping step. To key it by node identity instead, pass a KeyedSelectionModel, .keyed_selection(..) (NodeId-keyed) or from_source_keyed(source, keyed), which survives expand / collapse / filter / reorder.


Editing

The widget is the keyboard handler; the cell delegate is the editor swap. Wire it in three lines:

#![allow(unused)]
fn main() {
let table = TableView::from_source(proxy)
    // ...
    .edit_triggers(EditTriggers::default())            // F2 | ANY_KEY | DOUBLE_CLICK
    .on_cell_edit_request(|row, col_id, ctx| {
        // open your editor: a TextInputField bound to the row's value,
        // a date picker, a colour picker, …
    });

let column = Column::new("amount", lit!("Amount"), move |row, ctx| {
    if ctx.is_editing {
        // Swap in your editor while editing_cell_signal matches.
        Box::new(TextInputField::new(state_for(row.id)))
    } else {
        Box::new(TextWidget::new(lit!(format!("{}", row.amount))))
    }
})
.editable(true);
}

EditTriggers selects which gestures begin an edit. It is a bitflag set, not a closed enum: compose the flags you want with |.

FlagGesture
EditTriggers::NONEnothing the view does opens an editor
EditTriggers::F2F2 on the focused cell
EditTriggers::ANY_KEYany printable character typed on the focused cell
EditTriggers::SINGLE_CLICKone click on the cell; claims the press, so it no longer selects the row
EditTriggers::DOUBLE_CLICKdouble-click on the cell; takes the gesture from row activation on that column only
EditTriggers::ALLevery flag at once
#![allow(unused)]
fn main() {
.edit_triggers(EditTriggers::F2 | EditTriggers::SINGLE_CLICK)
}

EditTriggers::default() is F2 | ANY_KEY | DOUBLE_CLICK (Excel-like). Set it per view with TableView::edit_triggers / TreeTableView::edit_triggers, or per column with Column::edit_triggers, which wins where a column sets one. Only cells of a Column::editable column ever open an editor, whatever the triggers say. Note that ANY_KEY does not deliver the opening keystroke into the editor (the editor does not exist until the next build), so it reads as "F2 with an extra key" and it shadows type-ahead on every editable column.

editing_cell_signal is the source of truth for "which cell is in edit mode"; begin_edit and end_edit give you imperative control.

Escape ends the edit (the framework's keyboard handler reads editing_cell_signal and clears it before falling back to the focus clear behaviour).


Keyboard

KeyEffect
Arrow keysmove focused cell within the visible grid
Home / Endcell modes: first / last column of the row. Row modes: first / last row
Ctrl-Home / Ctrl-Endfirst / last row without moving a selection that can hold several rows or cells; a single one follows the cursor (a flat table in a cell mode jumps to the corner cell; TreeTableView keeps the column)
PgUp / PgDnscroll one page; focus moves the same number of rows
Tab / Shift+Tabnext / previous cell in row order, wrapping rows (configurable via tab_traversal)
Shift + Arrowextend selection in MultiRow / MultiCell modes
Ctrl + Arrowmove the focused cell without touching a selection that can hold several rows or cells; a single one follows the cursor
Ctrl-Tab / Ctrl-Shift-Tableave the table (next / previous focusable widget), the escape from cell traversal
Spacecheck the focused cell if it holds a checkbox, else toggle selection at focus
Ctrl-Space / Shift-SpaceMultiCell: select the column / the row (Excel and the ARIA grid pattern)
Enterinvoke on_row_activate (or fall back to toggle-select)
Ctrl-Aselect all rows / cells in multi modes; Ctrl-Shift-A deselects
F2 / typingbegin edit (gated by EditTriggers)
Escapeend edit if any, else clear focus
ArrowLeft on tree columncollapse the row when expanded, else move to its parent (TreeTableView)
* / + / -expand the whole subtree / expand one level / collapse (TreeTableView)
ArrowRight on tree columnexpand the row when collapsed and has children (TreeTableView)
On a full-width rowArrowLeft / ArrowRight act as on the tree column, from any column; it is one cell to Home / End and Tab, and opens no editor (TreeTableView, see Group rows)

The same handler powers both widgets via the RowNavigator trait, FlatNavigator for TableView, TreeNavigator for TreeTableView.


Group rows and pinned ancestors (TreeTableView)

A tree whose upper levels are groups, songs under albums under artists, reads best when the group rows span the whole table ("an artist · 12 songs | 3 albums | 52:10") and the group being scrolled through stays in sight. Two builders do that:

#![allow(unused)]
fn main() {
let library = TreeTableView::from_projection(proxy.clone())
    .add_column(title_col)        // the tree column
    .add_column(album_col)
    .add_column(time_col)
    .full_width_row(|row: &Row| !row.is_song())
    .full_width_row_delegate(|row, _cx| {
        Box::new(TextWidget::new(lit!(row.summary())))
    })
    .pinned_ancestors(2);         // the artist, then the album
}

Full-width rows

full_width_row(|item| -> bool) picks the rows drawn as one cell across every column; full_width_row_delegate(|item, &CellContext| -> Box<dyn Widget>) builds that cell, the band, the way a column delegate builds a cell. Without a delegate the band shows the tree column's own cell, laid across the row. The band's CellContext describes it as the tree column: col_id and col_index are the tree column's, is_tree_column is true, is_focused holds while the cursor is on the row in any column, and is_selected while the row is selected (in a cell mode, while any of its cells is). A row whose item is still loading is never full-width.

The row stays a row of the tree:

  • it selects, activates (Enter, the click activate_on names), drags, takes drops and answers type-ahead as any row does;
  • its indent and chevron are drawn in the band, where the tree column draws them; the chevron toggles it, and ArrowLeft / ArrowRight collapse and expand it whatever column the cursor is in;
  • in cell navigation it is one cell: ArrowLeft / ArrowRight act as on the tree column, so ArrowLeft on a collapsed band moves up to its parent as on any row; Home / End leave the cursor where it is; Tab and Shift+Tab pass it in one press; no column of it opens an editor (F2, typing, begin_edit all do nothing on it). The cursor keeps the column it arrived with, so ArrowDown onto an ordinary row lands back in that column.

Width and horizontal scroll. The band is as wide as the columns together, or the row when they are wider, starting at the row's leading edge. It stays in the viewport while the columns scroll sideways, so a group row stays readable. Its chevron sits where the tree column's does with the columns unscrolled: in line with the rows around it while the tree column is the first column (the default) or a pinned one.

Row height follows the view's mode like any row's: the uniform height, the row_height_fn callback, or under auto_row_height the band measured at its width. Column dividers (GridLines::Vertical) stop at a band rather than crossing it; the alternating tint, the selection band and the horizontal grid lines run across it as across any row, to the same width. The focus ring surrounds the whole band.

Pinned ancestors

pinned_ancestors(depth) keeps the ancestor rows of the first visible row pinned under the header, outermost first and at most depth levels of them, the "sticky scroll" of code editors. 0, the default, pins nothing.

  • A pinned row is pushed up by the next row at its level as that row arrives, as ListView's pinned section header is, so it never covers a row it is not an ancestor of. A deeper pinned row slides under the one above it.
  • A pinned row is a copy: the real row's cells, or its band for a full-width row, at the real row's height, on the header's SurfaceRole::Raised surface. It shows the row's content, not its selection or focus (its delegates are told neither), and its chevron is a picture. A copy is rebuilt a frame after the scroll that changes the row it stands for, and is hidden until then rather than showing the previous row; a line under a hidden one is hidden too.
  • Under auto_row_height the copy measures its row, as the row measures itself once laid out. An ancestor the view scrolled past without laying it out (a jump into the middle of a long group) takes its measured height a frame after it is pinned; a reveal made before then counts it at the estimate.
  • A press on a pinned row does what a plain click on the real row would: it ends an open cell edit (on_cell_edit_dismissed), selects the row and puts the cursor on it (the cursor keeps its column), activates it under ActivateOn::SingleClick, and scrolls it back into view right under its own pinned ancestors. A double click on a copy does not activate the row, and a press never reaches a control drawn inside the copy.
  • Every reveal the view makes stops below the ancestors that would be pinned over the row: the keyboard moving the cursor, ensure_row_visible, scroll_to_row (which aligns the row under its ancestors rather than to the very top), taking focus, and the rows' ScrollIntoView action. So a copy never covers the row the cursor is on.
  • The copies are hidden from assistive technology and take no Tab stop; the real rows stay where they are in the tree, scrolled under the copies like any row above the viewport.
  • The stack is re-derived on every layout from the flattening, so an expand, a collapse, an insert or a re-source above or inside it shows on the next frame.

A drop over a pinned row lands on the row it shows, in its top, middle or bottom third like a drop on the row itself, and the insertion line or box is drawn over the copy. The rows scrolled under the stack take no drop until they come out from under it.


Drag & drop

Under a finger the rules differ, and they are shared with the other four data views: Data views under a finger covers when a press commits the selection, why both reorders, rows and columns, wait for a hold, why the column-header strip is a pan surface, and how the tree-table's drop bands widen.

Column resize

The grip is centred on the divider: it reaches RESIZE_HANDLE_WIDTH (default 4 px) into the cell on each side, the same PM_HeaderGripMargin convention QHeaderView uses. So a header cell owns two grips, the one at its reading-order trailing edge, which resizes its own column, and the one at its leading edge, which resizes its predecessor, whose trailing edge that same divider is. Aiming at the seam and landing a pixel late therefore still resizes, instead of cycling the sort or starting a reorder drag.

Two exceptions narrow a grip:

  • Pane seams. The leading grip is suppressed when the predecessor sits in a different pinned pane. Once scroll_x is nonzero the column on the far side of a seam is not the one visually adjacent to it, so that boundary is not a column divider.
  • Very narrow columns. Each half is capped at a quarter of the cell's width, so a column dragged down to a small min_width keeps a central band for click-to-sort and reorder-drag instead of becoming all grip.

The header strip paints a separator at every column boundary, independent of GridLines, in the header the separator is the affordance (it is the only thing showing where the grip is), which is why every desktop table draws header separators unconditionally. GridLines stays a body decoration.

Cursor switches to CursorIcon::ColResize over either grip and is held for the whole drag (the pointer is captured and can travel far outside the header). PointerDown captures the pointer and records the target column's width; PointerMove updates column_widths_signal. Two policies:

#![allow(unused)]
fn main() {
table.column_resize_policy(ColumnResizePolicy::Live)        // commit on every tick (default)
table.column_resize_policy(ColumnResizePolicy::OnRelease)   // commit on PointerUp
}

Under OnRelease nothing moves until the button comes up, so the view paints a full-height guide line at the prospective divider for the duration of the drag, the same rubber band Qt and Excel show.

Only the columns after the divider reflow. The grabbed divider follows the pointer one-for-one; the columns before it keep their widths and the Flex columns after it absorb the difference (once none of them can, the pane overflows into horizontal scroll). That is NSTableView's and QHeaderView's behaviour, and it needs one thing from the write: a Flex column that precedes the resized one and has no override yet is frozen at its current width in the same update. Otherwise the solver would share the resized column's delta among every flex column, the preceding ones included, so the column's leading edge would slide the other way and the divider would track the pointer at a fraction of its speed (or not at all, with all the remaining flex weight ahead of it). The frozen widths are the ones already on screen, so nothing jumps; they simply stop flexing on later window resizes, exactly as a column the user sized by hand does.

Where the frozen widths go depends on whose map it is:

  • The view's own column_widths_signal: into it, with the resized column. Apps that persist it will therefore see entries for those columns too, and a restored layout matches the one the user left.
  • An adopted map (bind_column_widths, TableHeader::widths): into the view, and the map gets the resized column alone. An adopted map may be shared by a view of another width, where the frozen columns have other widths; written into the map, they would stop flexing there too and leave it a gap or an overflow. The view keeps its frozen widths until the map's entries for its columns change from anywhere else (a reset, a setter, another view resizing a column the two share), and is laid out from the map alone again from then on. A persisted adopted map restores the widths the user set; the flex columns flex again.

The committed width is clamped to the column's [min_width, max_width] before it is written, so column_widths_signal, the handle apps read back and persist, always mirrors what the table actually renders (the solver re-applies the same clamp when it resolves widths).

The handler converts window-space pointer coordinates into cell-local coordinates using the cell's window origin, captured in place_children. Without that translation, the resize zone test would misfire from anywhere in any column past the first one.

A drag is abandoned if the window goes inactive mid-gesture: the OS delivers no PointerUp to a window that lost focus with the button down, and the stale state would otherwise keep dragging the column on the next bare pointer move.

Accessibility. A resizable column header advertises AccessKit Increment / Decrement, each stepping the column by COLUMN_RESIZE_STEP (8 px) with the same clamping as a drag, the non-pointer path for screen readers, switch access, and the automation MCP. No numeric value or range is published on the ColumnHeader node: it would be announced on every ordinary pass over the table, which costs the common case to serve a rare one.

Column reorder

Drag a header cell from outside the resize zone, after a long press if the pointer is coarse, so a finger's swipe along the strip stays a horizontal pan (data-view-touch.md §3). The column-reorder drag emits ColumnReorderDragData { col_id, source_table_id }. The header strip is the drop target; dropping inside the leading-pinned pane re-pins the column to Leading, dropping inside the trailing-pinned pane re-pins to Trailing, otherwise the column joins the unpinned middle stream, a column declared .pinned(..) included (its column_pinning_signal entry is then PinnedSide::None; a drop back into the pane it declares removes the entry). Only the dragged column moves in column_order_signal; every other entry, including those of columns the view lacks, stays where it was. A pane exists only while a column is pinned to it, so with nothing pinned a drop at either end of the strip is a plain move to the first or last slot. Inter-table drops are rejected by source_table_id mismatch.

Row reorder

Row drag-and-drop is owned by the backing source, not the view (see data-source.md §3). The view computes a geometric (target, position), asks the source can_accept on every hover (an insertion line shows an accepted landing; a Reject suppresses it), and commits via the source's accept_drop on release, there is no on_row_drop callback. target is a row index resolved from the pointer's y the same way a click resolves one, see "Which row a y coordinate resolves to" above for the zero-height-row tie-break that keeps a click and a drop agreeing.

TableView. Set .reorderable(true) on the table (distinct from Column::reorderable, which reorders columns and defaults to true; the table-level flag reorders rows and defaults to false); a row drag emits the shared RowDragData<T> { source: ViewId, rows, items }. An intra-table reorder is a DragSource::SameView the source's accept_drop applies (a ListModel<T> reorders in place); a cross-table or external drop arrives as DragSource::Foreign { payload } at the same accept_drop, which downcasts the payload. Keyboard reorder (Alt+Arrow, Alt+Home/End) turns the move into the same (target, position) a pointer drop carries and commits it through the same accept_drop / reorder_within.

TreeTableView. Set .reorderable(true); a row drag routes through the tree source with the cycle guard, tree_apply_reorder refuses to drop a node into its own subtree, and handles the insertion-vs-reparent (Before/After sibling vs Into child) index math. Reorder is suppressed while the view is sorted by one of its own columns (a sorted projection has no stable insertion target); a shared bind_sort naming a column the view lacks does not count. Alt+Arrow keyboard reorder is likewise routed through the source.


Accessibility

  • TableView root: Role::Grid (a keyboard-driven, selectable table; Role::Table when TableSelectionMode::None) with row_count (header inclusive when shown) + column_count.
  • TreeTableView root: Role::TreeGrid, same counts.
  • Each header cell: Role::ColumnHeader with column_index and, on the active sort column, sort_direction.
  • Each body row: Role::Row with row_index (1-based; header is row 1, first body row is row 2). On TreeTableView, the row also carries level (1-based depth), expanded for non-leaf rows, and its position among its siblings.
  • Each body cell: Role::Cell (Role::GridCell in the cell-selection modes, where the cell is the selectable unit) with row_index and column_index, plus selected reflecting the current selection.
  • A TreeTableView full-width row keeps its row node (level, expanded, position) and holds one cell: column index 1 with a column_span of every displayed column, the band as its content, and the level and expanded state the tree column's cell would carry. No AccessKit adapter in this workspace reads column_span yet; it is the property AccessKit defines for this, on the node it belongs to.
  • Pinned ancestor rows are copies, hidden from assistive technology with their whole subtree; the real rows stay where they are in the tree.
  • The filter popover's trigger inherits the popover's set_expanded state and is named "Filter", locating it via screen-reader search is the same as locating any popover button.

Virtualization vs accessibility: only rendered rows materialize cell nodes, but set_row_count(total) keeps screen readers aware of the full size. Action::ScrollIntoView on an unmaterialized row routes through ensure_row_visible, which is the same path the keyboard PgDn handler uses.


Theme tokens

SurfaceRole
header backgroundSurfaceRole::Raised
header bottom dividerBorderRole::DividerStrong
body even-row bgnone (the surface behind shows through)
body odd-row bg (alternating_rows)SurfaceRole::AltRow
row selected bgSurfaceRole::Selected while the view is focused and the window active, else SurfaceRole::SelectedInactive
cell focus ringBorderRole::Focused
grid linesBorderRole::Divider
sort indicator (active)TextRole::Accent
filter glyph (inactive)TextRole::Secondary
filter glyph (active)TextRole::Accent

Static numbers (ROW_HEIGHT, HEADER_HEIGHT, RESIZE_HANDLE_WIDTH, GRID_LINE_THICKNESS, TREE_INDENT_PER_LEVEL, …) are pub consts in recipe_table_style They are snapshot at build time, like every other widget. The row and header heights a view lays out come from the active TableStyle's row_height and header_height, which RecipeTableStyle answers from its TableRecipe; ROW_HEIGHT and HEADER_HEIGHT are the IntUI values that recipe starts from.


What is and isn't shipped

Shipped: virtualized flat + hierarchical bodies, header drag-resize, header drag-reorder (with cross-pane re-pinning), pinned columns (Leading / Trailing), sort cycle (None → Asc → Desc → None), filter popover with reset, MultiRow / MultiCell selection with shift + ctrl semantics, full keyboard nav with focus ring, edit hooks via editing_cell_signal + on_cell_edit_request, row drag-drop reorder on TableView and TreeTableView, tree expand/collapse via twist + ArrowLeft/Right, tree filter modes, Role::Table / TreeGrid (Role::Grid when selectable) accessibility with row indices and sort direction, sort / width / order / filter state adopted from application signals (bind_sort, bind_column_widths, bind_column_order, bind_filters) and shared between views, the column header as a widget of its own (TableHeader over ColumnSpecs, with resolved_widths_signal) for rows an application lays out itself, and on TreeTableView full-width group rows (full_width_row + full_width_row_delegate) and pinned ancestor rows (pinned_ancestors).

Intentionally not shipped:

  • spreadsheet-style cell merging at the layout level (the one cell that spans columns is a TreeTableView full-width row's band, which spans all of them),
  • formula evaluation / computed cells,
  • multi-row column-group headers,
  • footer / summary rows (compose a StatusBar below the table),
  • in-table filter chip bar.

Deltas you may notice: Column::header_override is stored on the column but the default header rendering ignores it for now; Column::alignment and Column::truncation are likewise persisted on the descriptor but the user's cell delegate handles its own alignment and truncation; row_header_column, cell_label, row_label, and auto_truncation_tooltip builders are not yet wired (their row-header role and name-override slots exist on CellA11y). These are gaps, not bugs.


Demos

  • cargo run -p data-grid, 1000-row flat TableView with SortFilterListModel, MultiRow selection, alternating rows, and filterable name/email/role columns.
  • cargo run -p tree-table-view, mock filesystem TreeTableView with KeepAncestors filtering, twist-arrow expand/collapse, folders drawn as full-width rows, the folders above the first visible row pinned under the header, and the same drag-resize / drag-reorder behaviour as the flat table.