Skip to content

Guide Recipes ListTreeRow

MeowLynxSea edited this page Jan 24, 2026 · 3 revisions

Recipe: Tree / disclosure row (VirtualRow + ListItem + Disclosure + context menu)

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(...).

Building blocks

  • VirtualList + VirtualRow for virtualization-safe rows
  • ClickableSurface for row click handling
  • Disclosure for expand/collapse indicator
  • ListItem for standardized row layout
  • Popover + ContextMenuTrigger for an optional context menu

Example

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()
        })
    }
}

Notes

  • Prefer keys derived from your model (id/path) rather than list indices.
  • Use VirtualRow to avoid Location::caller() id collisions when GPUI recycles rows.

Clone this wiki locally