-
Notifications
You must be signed in to change notification settings - Fork 9
Guide Recipes ListTreeRow
MeowLynxSea edited this page Jan 24, 2026
·
3 revisions
This recipe shows a common "tree row" pattern:
- Click to expand/collapse
- Correct virtualization behavior (stable row id + spacing)
- 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(...).
-
VirtualList+VirtualRowfor virtualization-safe rows -
ClickableSurfacefor row click handling -
Disclosurefor expand/collapse indicator -
ListItemfor standardized row layout -
Popover+ContextMenuTriggerfor an optional context menu
use gpui::{AnyElement, ElementId, ListAlignment, div, px};
use yororen_ui::{
component::{
badge, clickable_surface, context_menu_trigger, disclosure, label,
list_item, popover, virtual_row,
},
widget::{virtual_list, virtual_list_state, VirtualListController},
};
use yororen_ui::theme::{ActionVariantKind, ActiveTheme};
struct TreeView {
list_state: gpui::ListState,
controller: VirtualListController,
// In a real app, this would come from your model.
rows: Vec<String>,
}
impl TreeView {
fn new(_cx: &mut gpui::App) -> Self {
let list_state = virtual_list_state(8, ListAlignment::Top, px(256.));
let controller = VirtualListController::new(list_state.clone());
Self { list_state, controller, rows: (0..8).map(|i| format!("Node {i}" )).collect() }
}
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
let expanded = window.use_keyed_state("recipe:tree:expanded", cx, |_, _| vec![false; 8]);
let controller = self.controller.clone();
let rows = self.rows.clone();
virtual_list(self.list_state.clone(), move |ix, _window, cx| -> AnyElement {
let is_expanded = expanded.read(cx).get(ix).copied().unwrap_or(false);
let row_key = ElementId::from((ElementId::from("recipe:tree"), ix));
// Context menu state for this row.
let menu_open = _window.use_keyed_state((row_key.clone(), "menu"), cx, |_, _| false);
let is_menu_open = *menu_open.read(cx);
let trigger = context_menu_trigger()
.id((row_key.clone(), "trigger"))
.variant(ActionVariantKind::Neutral)
.on_open({
let menu_open = menu_open.clone();
move |_ev, _window, cx| {
menu_open.update(cx, |v, _| *v = true);
}
})
.child(
clickable_surface()
.rounded_md()
.variant(ActionVariantKind::Neutral)
.on_click({
let expanded = expanded.clone();
let controller = controller.clone();
move |_ev, _window, cx| {
expanded.update(cx, |state, _| {
if let Some(v) = state.get_mut(ix) {
*v = !*v;
}
});
// Height changed.
controller.splice(ix..ix + 1, 1);
}
})
.child(
list_item()
.leading(disclosure().expanded(is_expanded))
.content(div().flex().items_center().gap_2().child(
label(rows.get(ix).cloned().unwrap_or_default()).strong(true),
))
.secondary(
if is_expanded {
label("Expanded content (height changes)").muted(true)
} else {
label("Click to expand").muted(true)
},
)
.trailing(badge(if is_expanded { "Expanded" } else { "Collapsed" })),
),
);
virtual_row(row_key.clone())
.gap_below(px(6.))
.child(
popover()
.id((row_key.clone(), "menu-popover"))
.open(is_menu_open)
.on_close({
let menu_open = menu_open.clone();
move |window, cx| {
menu_open.update(cx, |v, _| *v = false);
window.refresh();
}
})
.trigger(trigger)
.content(
div()
.py_1()
.child(label("Context menu").muted(true))
.child(div().h(px(1.)).bg(cx.theme().border.divider)),
),
)
.into_any_element()
})
}
}- Prefer keys derived from your model (id/path) rather than list indices.
- Use
VirtualRowto avoidLocation::caller()id collisions when GPUI recycles rows.
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.