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 activeTableStyle'srow_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). Arow_height(..)the app sets wins over the style. - Exact (
row_height_fn), a pure callbackfn(visible_index) -> f32seeds 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_yis 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.
- Scroll anchoring: when a correction shifts content above the
viewport top,
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
SortFilterListModelnotifies with a blanketReset; - expanding/collapsing a
TreeTableViewnode 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
TreeTableViewnode 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:
| Field | Meaning |
|---|---|
row_index | visible row (post sort/filter) |
col_id | the column's stable id |
col_index | display position (0-based, post pin + reorder) |
is_selected | true when this row (or cell, in cell-mode) is selected |
is_focused | true when this cell carries the keyboard focus |
is_hovered | true when the pointer is over this cell |
is_editing | true when editing_cell_signal == Some((row, col_index)) |
depth | Some(level) in TreeTableView, None in TableView |
is_tree_column | true 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_signalis 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.).
| Signal | Type | Mutated by | Persist via |
|---|---|---|---|
sort_signal | Signal<Option<(String, SortDirection)>> | header click cycle, set_sort, clear_sort | SettingsFile<T> |
filters_signal | Signal<HashMap<String, String>> | filter popover, set_filter, clear_filters | SettingsFile<T> |
column_widths_signal | Signal<HashMap<String, f32>> | header drag-resize, set_column_width | SettingsFile<T> |
column_order_signal | Signal<Vec<String>> | header drag-reorder, set_column_order | SettingsStore key (e.g. table.order) |
column_pinning_signal | Signal<HashMap<String, PinnedSide>> | drag across pane boundary, set_column_pinning | SettingsFile<T> |
focused_cell_signal | Signal<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
Nonemeans 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. TheFlexcolumns 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 aTreeTableViewstill 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::Resetonce per upstream change (one rebuild, not two), except anItemUpdatedthat neither changes the row's filter verdict nor moves it past a neighbour, which is forwarded as a scopedItemUpdated(see below), - reports
first_changed_index()so measured row heights survive theReset.
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:
| Mode | Behaviour |
|---|---|
HideNonMatching | rows that don't match are hidden, taking their entire subtree with them |
KeepAncestors | a match keeps every ancestor visible (file-tree convention; the default) |
KeepDescendants | a 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::Accentwhen the column has an active filter andTextRole::Secondaryotherwise.
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:
| Mode | Backing model | Notes |
|---|---|---|
None | , | clicks just move focus |
SingleRow | teksilo_data::SelectionModel | replaces; modifier keys ignored |
MultiRow (default) | teksilo_data::SelectionModel with SelectionMode::Multi | Ctrl-click toggles, Shift-click extends, Shift+Arrow extends |
SingleCell | CellSelectionModel | Excel-style; one (row,col) at a time |
MultiCell | CellSelectionModel | rectangular 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 |.
| Flag | Gesture |
|---|---|
EditTriggers::NONE | nothing the view does opens an editor |
EditTriggers::F2 | F2 on the focused cell |
EditTriggers::ANY_KEY | any printable character typed on the focused cell |
EditTriggers::SINGLE_CLICK | one click on the cell; claims the press, so it no longer selects the row |
EditTriggers::DOUBLE_CLICK | double-click on the cell; takes the gesture from row activation on that column only |
EditTriggers::ALL | every 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
| Key | Effect |
|---|---|
| Arrow keys | move focused cell within the visible grid |
| Home / End | cell modes: first / last column of the row. Row modes: first / last row |
| Ctrl-Home / Ctrl-End | first / 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 / PgDn | scroll one page; focus moves the same number of rows |
| Tab / Shift+Tab | next / previous cell in row order, wrapping rows (configurable via tab_traversal) |
| Shift + Arrow | extend selection in MultiRow / MultiCell modes |
| Ctrl + Arrow | move the focused cell without touching a selection that can hold several rows or cells; a single one follows the cursor |
| Ctrl-Tab / Ctrl-Shift-Tab | leave the table (next / previous focusable widget), the escape from cell traversal |
| Space | check the focused cell if it holds a checkbox, else toggle selection at focus |
| Ctrl-Space / Shift-Space | MultiCell: select the column / the row (Excel and the ARIA grid pattern) |
| Enter | invoke on_row_activate (or fall back to toggle-select) |
| Ctrl-A | select all rows / cells in multi modes; Ctrl-Shift-A deselects |
| F2 / typing | begin edit (gated by EditTriggers) |
| Escape | end edit if any, else clear focus |
| ArrowLeft on tree column | collapse the row when expanded, else move to its parent (TreeTableView) |
* / + / - | expand the whole subtree / expand one level / collapse (TreeTableView) |
| ArrowRight on tree column | expand the row when collapsed and has children (TreeTableView) |
| On a full-width row | ArrowLeft / 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_onnames), 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_editall 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::Raisedsurface. 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_heightthe 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 underActivateOn::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_xis 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_widthkeeps 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
TableViewroot:Role::Grid(a keyboard-driven, selectable table;Role::TablewhenTableSelectionMode::None) withrow_count(header inclusive when shown) +column_count.TreeTableViewroot:Role::TreeGrid, same counts.- Each header cell:
Role::ColumnHeaderwithcolumn_indexand, on the active sort column,sort_direction. - Each body row:
Role::Rowwithrow_index(1-based; header is row 1, first body row is row 2). OnTreeTableView, the row also carrieslevel(1-based depth),expandedfor non-leaf rows, and its position among its siblings. - Each body cell:
Role::Cell(Role::GridCellin the cell-selection modes, where the cell is the selectable unit) withrow_indexandcolumn_index, plusselectedreflecting the current selection. - A
TreeTableViewfull-width row keeps its row node (level, expanded, position) and holds one cell: column index 1 with acolumn_spanof 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 readscolumn_spanyet; 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_expandedstate 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
| Surface | Role |
|---|---|
| header background | SurfaceRole::Raised |
| header bottom divider | BorderRole::DividerStrong |
| body even-row bg | none (the surface behind shows through) |
body odd-row bg (alternating_rows) | SurfaceRole::AltRow |
| row selected bg | SurfaceRole::Selected while the view is focused and the window active, else SurfaceRole::SelectedInactive |
| cell focus ring | BorderRole::Focused |
| grid lines | BorderRole::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
TreeTableViewfull-width row's band, which spans all of them), - formula evaluation / computed cells,
- multi-row column-group headers,
- footer / summary rows (compose a
StatusBarbelow 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 flatTableViewwithSortFilterListModel,MultiRowselection, alternating rows, and filterable name/email/role columns.cargo run -p tree-table-view, mock filesystemTreeTableViewwithKeepAncestorsfiltering, 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.