Skip to content

Design Virtualization

MeowLynxSea edited this page Jun 13, 2026 · 3 revisions

Virtualization (v0.3 API)

Virtualization keeps scrolling smooth by only building and laying out the rows that are visible. In v0.3 the headless layer ships two list primitives — virtual_list (variable-height) and uniform_virtual_list (fixed-height, faster) — both as headless::virtual_list::* factories in crates/yororen-ui-core/src/headless/virtual_list.rs.

The big v0.3 shift: the list is closure-driven, not a struct you own. The caller keeps a thin controller on their view, hands it to the factory, and supplies a FnMut(usize, &mut Window, &mut App) -> AnyElement row closure. The renderer wraps the closure in the appropriate gpui::list or gpui::uniform_list element.

Two factories, two controllers

Factory Controller Wraps Trade-off
virtual_list(id, &controller, cx) VirtualListController gpui::ListState Variable-height rows; on_visible_range_change works.
uniform_virtual_list(id, count, &controller, cx) UniformVirtualListController gpui::UniformListScrollHandle Same-height rows only; ~10× faster for very large lists. No on_visible_range_change.

VirtualListController is a &self handle over an internal gpui::ListState (Rc<RefCell<…>>). It's cheap to clone. UniformVirtualListController is the same shape over a UniformListScrollHandle. The uniform variant does not own the item count — you pass the count each frame to uniform_virtual_list(id, count, &controller, cx).

Why a controller and not raw gpui::ListState?

  • The underlying ListState only has a new(count, alignment, overdraw) constructor and no other public API surface to extend.
  • The controller is a small ergonomic wrapper that exposes reset / splice / append / scroll_to_reveal_item / scroll_to_top / scroll_to_bottom as &self methods. The inner RefCell makes the mutation safe.
  • It also pins the default alignment (Top) and overdraw (16px) so the caller doesn't repeat them at every call site.

VirtualListController

use yororen_ui::headless::virtual_list::VirtualListController;
use gpui::{ListAlignment, px};

impl MyState {
    pub fn new(cx: &mut gpui::App) -> Self {
        Self {
            list_controller: VirtualListController::with_default(1_000),
            // …or: VirtualListController::new(count, ListAlignment::Top, px(16.))
        }
    }
}

Methods (all &self):

Method What it does
with_default(item_count) Convenience: Top alignment, 16px overdraw.
new(item_count, alignment, overdraw) Full constructor.
state() Snapshot the inner ListState for the renderer.
reset(element_count) Replace the total item count (after bulk add/remove).
splice(old_range, count) Replace a range with count new items.
append(n) Append n items at the tail — preserves scroll position.
scroll_to_reveal_item(ix) Make item ix visible.
scroll_to_top() Jump to item 0.
scroll_to_bottom() Jump to the last item (forces re-measure of the tail).

append(n) is the right call for infinite loading: it does splice(item_count..item_count, n) internally, so the existing logical_scroll_top is preserved and the user's scroll position stays put while new tail items become available.

UniformVirtualListController

use yororen_ui::headless::virtual_list::UniformVirtualListController;

impl MyState {
    pub fn new(cx: &mut gpui::App) -> Self {
        Self {
            uniform_list_controller: UniformVirtualListController::new(),
        }
    }
}

Methods:

Method What it does
new() Mint a fresh controller.
handle() Snapshot the inner scroll handle.
scroll_to_item(ix) Scroll so item ix is at the top.
scroll_to_top() Scroll to item 0.
scroll_to_bottom() Scroll to the last item.

The row closure

Both factories take the closure via .row(...). It's Box<dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static> — the renderer wraps it in whatever RenderOnce shell it needs (VirtualListElement for the default renderer, etc.).

use yororen_ui::headless::virtual_list::virtual_list;

let row_closure_entity = cx.entity().clone();
let row_closure_template = cx.t("demo.lists.vl_item").to_string();

let vl = virtual_list("rows-vl", &app.list_controller, cx)
    .row(move |ix, _window, cx| {
        let app = row_closure_entity.read(cx);
        let row_label = row_closure_template.replacen("{}", &ix.to_string(), 1);
        list_item(format!("vl-row-{ix}"), row_label, cx)
            .selected(app.selected == Some(ix))
            .render(cx)
            .into_any_element()
    })
    .render(cx)
    .w(px(240.))
    .h(px(180.));

The closure must produce a gpui::AnyElement (use .render(cx)… .into_any_element()). It runs on the gpui main thread inside a row-scoped with_element_namespace, so ids in child elements collide freely across rows.

on_visible_range_change — infinite loading

virtual_list (the variable-height one) supports .on_visible_range_change(F) where F is FnMut(Range<usize>, usize, &mut Window, &mut App) + 'static. The renderer hands it to gpui::ListState::set_scroll_handler. Typical pattern:

use yororen_ui::headless::virtual_list::virtual_list;
use std::ops::Range;

let entity_for_range = cx.entity().clone();
let vl = virtual_list("feed-vl", &app.feed_controller, cx)
    .row(/* per-row element */)
    .on_visible_range_change(move |range: Range<usize>, total, _w, cx| {
        entity_for_range.update(cx, |s, _cx| {
            // Within N items of the end — load another batch.
            if range.end + 50 >= total && s.feed_count < 50_000 {
                s.feed_count += 100;
            }
        });
    })
    .render(cx);

Don't call controller.reset(...) from inside the callback. ListState is Rc<RefCell<…>> and the gpui scroll path is currently borrowing it. Resetting would re-enter the RefCell and panic with "RefCell already borrowed". Instead, bump a count in your entity (above) and call controller.reset(...) at the top of the next Render impl, where the borrow is released. The gallery_demo's vl_item_count + auto-load is exactly this pattern — see crates/yororen-ui-demos/gallery_demo/src/sections/lists.rs.

Controller + Entity: the Send + Sync wrinkle

ButtonProps::on_click is Send + Sync, but VirtualListController is Rc<RefCell<…>> and single-threaded. The way the gallery wires its Top / Bottom buttons:

let entity_for_vl_top = cx.entity().clone();
let top_btn = button("vl-top", cx)
    .on_click(move |_, _, cx| {
        // Route through Entity so the closure stays Send + Sync.
        entity_for_vl_top.update(cx, |s, _| {
            s.list_controller.scroll_to_top();
        });
    })
    .render(cx);

The Entity<MyApp> is Send + Sync (it's an internal Arc), so the on_click closure is happy. The update callback runs on the main thread and reaches the controller.

Worked example — gallery_demo infinite list

The crates/yororen-ui-demos/gallery_demo/src/sections/lists.rs file is the canonical reference. The pattern:

  1. App state owns list_controller: VirtualListController and a vl_item_count: usize that tracks how many items the data has.
  2. The render closure reads app.vl_item_count and calls controller.reset(app.vl_item_count) first (via entity.update), then constructs virtual_list("lists-vl", &controller, cx).
  3. The row closure reads app and renders one list_item.
  4. on_visible_range_change bumps app.vl_item_count by 100 when within 50 of the end (capped at 5,000).

That covers:

  • 1000+ items that grow via infinite scroll
  • A live "visible range / item count / batch count" status label
  • Top / Bottom buttons wired through the entity

When to use which

  • virtual_list — variable-height rows (rows with different content sizes, expand/collapse, async-loaded details). Use it whenever your rows are not all exactly the same height.
  • uniform_virtual_list — fixed-height rows (chat bubbles, log lines, settings rows). Significantly faster than virtual_list because gpui::uniform_list measures only the first row. Requires equal-height rows.

Both share the same controller pattern and .row(...) API — only the controller type and the count argument differ.

Related pages

Clone this wiki locally