Skip to content

Guide Recipes ListTreeRow

MeowLynxSea edited this page Jun 18, 2026 · 3 revisions

Recipe: Tree / disclosure row

A tree-row pattern with a collapsible disclosure inside a virtual_list. The key detail: when a row's height changes, notify the list via controller.splice(...) so the virtualised list re-measures that row.

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

#[derive(Default)]
pub struct TreeExpansion { pub expanded: BTreeSet<usize> }
impl gpui::Global for TreeExpansion {}

pub struct TreeView {
    pub rows: Vec<String>,
    pub controller: VirtualListController,
}

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();
                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)
                    .on_click(move |_ev, _w, cx| {
                        cx.update_global::<TreeExpansion, _>(|state, _cx| {
                            if !state.expanded.insert(ix) { state.expanded.remove(&ix); }
                        });
                        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. Use the data's primary key (e.g. "row-{}", node.id) when the tree can reorder.
  • Height changes need splice. When the row's height changes (disclosure opens), call controller.splice(ix..ix+1, 1) so gpui::list re-measures that row. Without it, stale overdraw leaves visible gaps.
  • list_item.on_click is Fn(&ClickEvent, &mut Window, &mut App) — three args.
  • Disclosure chevron is in the renderer. You don't have to draw it; pass .open(true|false) and the renderer rotates the icon.
  • virtual_list is closure-driven. Avoid cx.spawn or file I/O inside the .row(...) closure.
  • Append-only streaming: prefer controller.append(n) over controller.reset(n)append preserves the user's scroll position.

See also

Clone this wiki locally