Virtualized list
Rendering a list with 100,000 items is impossible naively — the frame budget is 16ms and iterating every row would blow past that on even a warm cache. The trick is to render only the items you can see, which on a 40-row viewport is 10–50 out of 100,000. The hard part is mapping a scroll offset to “which row is at the top” when every row may have a different height.
VirtualizedList is another surface where the intelligence layer is
visible to app authors. It solves this with two data structures:
- A Fenwick tree (binary indexed tree) gives O(log n) queries for prefix
sums of row heights. Scroll offset
→item index becomes a single search. - A Bayesian height predictor with conformal bounds fills in heights for items you have not measured yet. It provides a 95%-confidence interval for each unknown height, so you can allocate realistic space without having touched the row.
Together they let the widget render only the visible rows, even when heights are variable and most rows have never been measured.
The math behind the height predictor is covered on bayesian-inference / height-prediction. This page focuses on the widget itself.
Source: virtualized.rs:45
(3,400+ lines).
Why the obvious approach fails
A naive list:
for item in &items {
if in_viewport(item, scroll) {
draw(item);
}
}is O(n) and scans every item even though most are off-screen. With 100K
items and a 40-row viewport that is 99,960 wasted comparisons per frame,
times 60 FPS, times whatever layout cost each measurement has. You cannot
afford it.
Fenwick tree for O(log n) scroll mapping
A Fenwick tree indexes the list by cumulative height. Querying the
cumulative height up to index i is O(log n). Inverting — “given a Y
coordinate, which index is at that row?” — is also O(log n) via binary
search on the tree structure.
Source: fenwick.rs.
The tree supports:
update(i, new_height)— O(log n). Called when an item’s measured height changes.prefix_sum(i)— O(log n). Sum of heights[0, i).find_by_prefix(y)— O(log n). Smallestisuch thatprefix_sum(i) > y.
That last operation is the workhorse. Each frame, the widget calls
find_by_prefix(scroll_offset) to decide which item starts at the top of
the viewport, then walks forward drawing until the cumulative height
exceeds viewport height.
For the variable-height version — used when most rows need measurement on
first display — see VariableHeightsFenwick.
Bayesian height predictor
Not every row has a real measurement. A freshly-loaded list of 100K has measurements for the 50 rows currently visible and nothing else. The Fenwick tree still needs a height for every entry.
The height predictor
(height_predictor.rs)
maintains the empirical distribution of measured heights (mean, variance)
and returns predictions for unmeasured rows plus a 95%-confidence interval:
The interval is a conformal bound — it is calibrated from recent measurements rather than assuming any parametric distribution.
Concretely, when a row is first loaded:
- Predictor returns
(mean, lo, hi). - Fenwick tree stores
meanfor that row. - The moment the row becomes visible, real measurement replaces the estimate.
- Predictor updates with the new sample.
The state
Source: virtualized.rs:1689.
pub struct VirtualizedListState {
pub scroll_offset: usize, // item index at top of viewport
pub selected: Option<usize>, // currently selected item, if any
pub overscan: usize, // extra items to render above/below viewport
pub item_height: ItemHeight, // Fixed | Variable | VariableFenwick
pub visible_range: Option<Range<usize>>, // cached for this frame
}scroll_offsetis expressed in item index, not pixels. The Fenwick tree converts as needed.overscanrenders a few items just outside the viewport so a scroll event of one step doesn’t momentarily paint blank rows.item_heightpicks a strategy:Fixed(h)— all rows are the same height; no Fenwick needed.Variable— uses a naive prefix-sum cache, O(n) worst case.VariableFenwick— the full machinery.
Render path
Each frame the widget:
Compute the visible range
find_by_prefix(scroll_offset) locates the top item. Walk forward summing
heights until the cumulative height meets viewport height. Add overscan
on each end.
Draw only the visible items
A loop over the visible range — typically 10 – 50 iterations regardless of total list size. Each item renders into its own sub-rect.
Measure any newly-rendered row
After drawing, the widget knows each visible row’s true height. It updates the Fenwick tree and the height predictor.
Clamp scroll_offset
If the user resized the viewport such that scroll_offset now points past
the end, the widget clamps it. This is the kind of mutation that justifies
StatefulWidget.
Worked example
A log viewer over a 200,000-row log file. Rows may wrap; heights vary from 1 to 6 rows.
use ftui_widgets::virtualized::{VirtualizedList, VirtualizedListState, ItemHeight};
pub struct Model {
pub rows: Vec<LogRow>,
pub list_state: VirtualizedListState,
}
impl Default for Model {
fn default() -> Self {
Self {
rows: Vec::new(),
list_state: VirtualizedListState {
scroll_offset: 0,
selected: None,
overscan: 3,
item_height: ItemHeight::VariableFenwick,
visible_range: None,
},
}
}
}
fn view(&self, frame: &mut ftui_render::frame::Frame) {
use ftui_widgets::StatefulWidget;
let area = frame.buffer.bounds();
let list = VirtualizedList::new(&self.rows)
.render_item(|row, area, frame| draw_log_row(row, area, frame));
StatefulWidget::render(&list, area, frame, &mut self.list_state);
}Memory-wise, you are carrying 200K rows of data plus a 200K-entry Fenwick
tree (≈ 1.6 MB for u64 heights). Render time is constant in list size.
Performance targets
On a modern laptop CPU the widget hits:
- 100,000-row list, 40-row viewport, mixed heights: < 2 ms per frame
- Scroll by one row: amortised O(log n)
- Full resize to a new viewport height: O(visible)
The dominant cost is the render closure itself, not the virtualization machinery.
Pitfalls
- Using
Variableinstead ofVariableFenwickfor large lists. The naive prefix cache is O(n) worst case; it will bite you somewhere past a few thousand rows. - Changing row heights after render. The Fenwick tree is updated
during render. Mutating heights in
update()leaves the tree stale until the next render. Keep height derivation inside the item’s own render closure if possible. - Forgetting to clamp
scroll_offsetexternally. If yourupdate()setsscroll_offsetpast the end, the next render will clamp it — correct, but the user sees one frame of “stuck at end”. - Not resetting
visible_rangewhen the list changes. If your data grew by 1,000 rows, the cached visible range from last frame may be stale. Setvisible_range = Nonewhenever the underlying data changes length. - Trying to scroll by pixel.
scroll_offsetis in item units; the Fenwick tree handles pixel conversion. Mixing units is the most common source of off-by-one bugs.
Where next
The math — conformal bounds, online updating, and why parametric priors are brittle here.
Bayesian height predictionThe plain List widget for small lists with fixed heights.
Multi-column version with its own column-constraint solver.
TablePair the virtualized list with a thumb-sized scrollbar.
ScrollbarHow this piece fits in widgets.
Widgets overview