-
Notifications
You must be signed in to change notification settings - Fork 9
Guide Recipes ListTreeRow
MeowLynxSea edited this page Jun 13, 2026
·
3 revisions
This recipe shows the common "tree row" pattern using the v0.3 headless API.
- Click to expand / collapse.
- Correct virtualization behavior (stable row id).
- Optional right-click context menu.
The key detail is: when a row's height changes (expanded
content appears / disappears), you must notify the list via
VirtualListController::splice(ix..ix+1, 1) so the virtualized
list re-measures that row.
-
virtual_list+VirtualListController— wrapsgpui::ListState. TheVirtualListControlleris a&selfhandle withreset / splice / append / scroll_to_reveal_item / scroll_to_top / scroll_to_bottom. -
list_item— a single row in a list. Optional.on_clickfor the click handler (Fn(&ClickEvent, &mut Window, &mut App)— 3 args). -
disclosure— collapsible content withopen: booland.on_toggle.disclosure(id, title, cx)takes 3 args (id, title,&mut App).
use std::collections::BTreeSet;
use gpui::{Context, IntoElement, ParentElement, Render, SharedString, Window, div, px};
use yororen_ui::headless::badge::badge;
use yororen_ui::headless::disclosure::disclosure;
use yororen_ui::headless::label::label;
use yororen_ui::headless::list_item::list_item;
use yororen_ui::headless::virtual_list::{
VirtualListController, virtual_list,
};
pub struct TreeView {
pub rows: Vec<String>,
pub controller: VirtualListController,
}
impl TreeView {
pub fn new(_cx: &mut gpui::App) -> Self {
Self {
rows: (0..8).map(|i| format!("Node {i}")).collect(),
controller: VirtualListController::with_default(8),
}
}
pub fn toggle(&mut self, ix: usize) {
// Height changed — ask the controller to re-measure this row.
self.controller.splice(ix..ix + 1, 1);
}
}
#[derive(Default)]
pub struct TreeExpansion {
pub expanded: BTreeSet<usize>,
}
impl gpui::Global for TreeExpansion {}
impl Render for TreeView {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let controller = self.controller.clone();
let rows = self.rows.clone();
let entity = cx.entity();
virtual_list("recipe:tree", &controller, cx)
.row(move |ix, _w, cx| {
let row_id = format!("recipe:tree-row-{ix}");
let label_text = rows.get(ix).cloned().unwrap_or_default();
// Read expansion from the global.
let is_expanded = cx
.try_global::<TreeExpansion>()
.map(|g| g.expanded.contains(&ix))
.unwrap_or(false);
let entity_for_click = entity.clone();
let controller_for_click = controller.clone();
list_item(row_id.clone(), label_text.clone(), cx)
.selected(false)
.on_click(move |_ev, _w, cx| {
// Toggle expansion, then ask the controller
// to re-measure this row (height changed).
let now_expanded = cx.update_global::<TreeExpansion, _>(|state, _cx| {
if !state.expanded.insert(ix) {
state.expanded.remove(&ix);
false
} else {
true
}
});
let _ = now_expanded;
controller_for_click.splice(ix..ix + 1, 1);
})
.render(cx)
.child(
disclosure(
format!("{row_id}-disc"),
if is_expanded { "Hide" } else { "Show" },
&mut *cx,
)
.open(is_expanded)
.render(&mut *cx),
)
.child(
label(format!("{row_id}-label"), label_text, cx)
.strong(true)
.render(cx),
)
.child(
badge(
format!("{row_id}-badge"),
SharedString::from(if is_expanded { "Expanded" } else { "Collapsed" }),
cx,
)
.render(cx),
)
.into_any_element()
})
.render(cx)
}
}-
Stable keys from your data model. The example uses
format!("recipe:tree-row-{ix}")because the data is append-only and never reorders. If your tree can reorder (drag-and-drop, sort, filter), use the node's primary key (e.g."row-{}", node.id) instead. -
Height changes need a
splicecall. When the row's height changes (disclosure opens, sub-content appears), callcontroller.splice(ix..ix+1, 1)sogpui::listre-measures that row. Forgetting this leaves stale overdraw and visible gaps. -
list_item.on_clickis 3-arg.Fn(&ClickEvent, &mut Window, &mut App)— usemove |_ev, _w, cx| { … }. -
Disclosure chevron is in the renderer. You don't have to
draw the chevron yourself; pass
.open(true|false)and the renderer rotates the icon.disclosure(id, title, cx)takes 3 args. -
virtual_listis closure-driven. The.row(closure)closure isBox<dyn FnMut + 'static>— it gets called per visible row. Avoidcx.spawnor file I/O inside; those go in event handlers. -
Append-only streaming. For a feed that grows at the
tail (chat, logs), prefer
controller.append(n)overcontroller.reset(n)—appendpreserves the user's scroll position;resetdoesn't. -
badge(id, text, cx)takes 3 args (id, text,&mut App);label(id, text, cx)andtag(id, label, cx)follow the same shape.
-
Guide-Composing-UI §2 —
virtual_list-
list_itemat the top level
-
- Guide-Recipes-ContextMenu — attaching a right-click menu to the row
- Component-Disclosure — full API reference
- Component-ListItem — full API reference
-
codex-skills/yororen-ui-state-inputs§7 — virtual list patterns
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.