-
Notifications
You must be signed in to change notification settings - Fork 9
Design Virtualization
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.
| 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).
- The underlying
ListStateonly has anew(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_bottomas&selfmethods. The innerRefCellmakes the mutation safe. - It also pins the default alignment (
Top) and overdraw (16px) so the caller doesn't repeat them at every call site.
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.
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. |
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.
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.ListStateisRc<RefCell<…>>and the gpui scroll path is currently borrowing it. Resetting would re-enter theRefCelland panic with"RefCell already borrowed". Instead, bump a count in your entity (above) and callcontroller.reset(...)at the top of the nextRenderimpl, where the borrow is released. Thegallery_demo'svl_item_count+ auto-load is exactly this pattern — seecrates/yororen-ui-demos/gallery_demo/src/sections/lists.rs.
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.
The crates/yororen-ui-demos/gallery_demo/src/sections/lists.rs
file is the canonical reference. The pattern:
- App state owns
list_controller: VirtualListControllerand avl_item_count: usizethat tracks how many items the data has. - The render closure reads
app.vl_item_countand callscontroller.reset(app.vl_item_count)first (viaentity.update), then constructsvirtual_list("lists-vl", &controller, cx). - The row closure reads
appand renders onelist_item. -
on_visible_range_changebumpsapp.vl_item_countby 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
-
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 thanvirtual_listbecausegpui::uniform_listmeasures 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.
- Design-Keyed-State — per-row element namespace isolation.
- Design-Three-Layer-Architecture — the headless factory vs renderer split for lists.
-
Composing UI — the
cx.entity().clone()pattern used to wire scroll buttons. - Recipe — Search + results list.
Yororen UI v0.3.0 · repository · Apache-2.0 · This wiki documents Yororen UI v0.3.0.
This wiki documents Yororen UI v0.3.0 — the headless-core, swappable-renderer build.