Skip to content

Guide Recipes ListTreeRow

MeowLynxSea edited this page Jun 13, 2026 · 3 revisions

Recipe: Tree / disclosure row (virtual_list + list_item + disclosure)

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.

Building blocks

  • virtual_list + VirtualListController — wraps gpui::ListState. The VirtualListController is a &self handle with reset / splice / append / scroll_to_reveal_item / scroll_to_top / scroll_to_bottom.
  • list_item — a single row in a list. Optional .on_click for the click handler (Fn(&ClickEvent, &mut Window, &mut App) — 3 args).
  • disclosure — collapsible content with open: bool and .on_toggle. disclosure(id, title, cx) takes 3 args (id, title, &mut App).

Example

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

Notes

  • 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 splice call. When the row's height changes (disclosure opens, sub-content appears), call controller.splice(ix..ix+1, 1) so gpui::list re-measures that row. Forgetting this leaves stale overdraw and visible gaps.
  • list_item.on_click is 3-arg. Fn(&ClickEvent, &mut Window, &mut App) — use move |_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_list is closure-driven. The .row(closure) closure is Box<dyn FnMut + 'static> — it gets called per visible row. Avoid cx.spawn or 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) over controller.reset(n)append preserves the user's scroll position; reset doesn't.
  • badge(id, text, cx) takes 3 args (id, text, &mut App); label(id, text, cx) and tag(id, label, cx) follow the same shape.

See also

Clone this wiki locally