Skip to content

Guide Recipes VirtualList Search

MeowLynxSea edited this page Jun 18, 2026 · 3 revisions

Recipe: Search + results list

The "search box + virtualized results" pattern using search_input + virtual_list.

Building blocks

  • search_input — text input with a search icon and a built-in × clear button. search_input(id) takes 1 arg; renders via .render(cx, window). on_clear fires after the renderer clears the input on Escape.
  • virtual_list + VirtualListController — wraps gpui::ListState. Use .on_visible_range_change for infinite loading.
  • list_item — the row content.

Example

use std::ops::Range;
use yororen_ui::headless::label::label;
use yororen_ui::headless::list_item::list_item;
use yororen_ui::headless::search_input::search_input;
use yororen_ui::headless::virtual_list::{VirtualListController, virtual_list};

pub struct SearchView {
    pub all_items:  Vec<String>,
    pub visible:    Vec<usize>,
    pub controller: VirtualListController,
}

impl SearchView {
    pub fn new(_cx: &mut gpui::App) -> Self {
        Self {
            all_items: Vec::new(),
            visible: Vec::new(),
            controller: VirtualListController::with_default(0),
        }
    }

    pub fn set_items(&mut self, items: Vec<String>) {
        self.visible = (0..items.len()).collect();
        self.all_items = items;
        self.controller.reset(self.visible.len());
    }

    pub fn apply_query(&mut self, q: &str) {
        self.visible = if q.is_empty() {
            (0..self.all_items.len()).collect()
        } else {
            let needle = q.to_lowercase();
            self.all_items.iter().enumerate()
                .filter(|(_, s)| s.to_lowercase().contains(&needle))
                .map(|(i, _)| i)
                .collect()
        };
        self.controller.reset(self.visible.len());
    }
}

impl Render for SearchView {
    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let entity = cx.entity();
        let entity_for_change = entity.clone();
        let controller_for_change = self.controller.clone();

        div().size_full().flex().flex_col().gap_3().p_4()
            .child(
                search_input("search-box")
                    .placeholder("Search…")
                    .on_change(move |value: &str, _w, cx| {
                        entity_for_change.update(cx, |s, _| s.apply_query(value));
                        let visible_len = entity_for_change.read(cx).visible.len();
                        controller_for_change.reset(visible_len);
                    })
                    .render(cx, _w),
            )
            .child({
                let entity_for_rows = entity.clone();
                virtual_list("search-results", &self.controller, cx)
                    .row(move |ix, _w, cx| {
                        let source_ix = entity_for_rows.read(cx).visible.get(ix).copied().unwrap_or(0);
                        let text = entity_for_rows.read(cx).all_items.get(source_ix).cloned().unwrap_or_default();
                        let row_id = format!("search-row-{source_ix}");
                        list_item(row_id, text.clone(), cx)
                            .render(cx)
                            .child(label(format!("search-row-label-{source_ix}"), text, cx).render(cx))
                            .into_any_element()
                    })
                    .render(cx)
            })
    }
}

Notes

  • Stable keys from the source data. Key by source_ix because visible rebuilds on every query. If your data can reorder, key by the data's primary id.
  • controller.reset(n) for full replacement. When the query changes the visible set rebuilds and the count changes.
  • controller.append(n) for tail-only growth. Preserves scroll position. The right call for "infinite loading".
  • controller.splice(old, new) for partial updates. If a row's height changes (disclosure opens) or you edit one row in place.
  • on_visible_range_change fires when the visible range changes (scroll, resize, item-count change). FnMut + 'static.
  • uniform_virtual_list is the fixed-height variant — same shape but measures only the first row. No on_visible_range_change support.
  • search_input clears on Escape automatically. The renderer resets the value and fires on_clear; your hook is for extra work (logging, re-fetching).

See also

Clone this wiki locally