Skip to content

Guide Recipes VirtualList Search

MeowLynxSea edited this page Jan 24, 2026 · 3 revisions

Recipe: Search + results list (SearchInput + VirtualList)

This recipe shows the common “search box + virtualized results” pattern.

Goal:

  • A search input with submit + clear
  • A result list that stays smooth for large datasets
  • Stable row identity and correct spacing under virtualization

Building blocks

  • SearchInput for query entry
  • VirtualList widget for scrolling + virtualization
  • VirtualRow for row identity and row-local namespace
  • ListItem for standardized row layout

Example

use gpui::{AnyElement, ListAlignment, SharedString, div, px};
use yororen_ui::{
    component::{label, list_item, search_input, virtual_row},
    widget::{virtual_list, virtual_list_state, VirtualListController},
};

struct SearchView {
    list_state: gpui::ListState,
    controller: VirtualListController,
    items: Vec<String>,
}

impl SearchView {
    fn new(cx: &mut gpui::App) -> Self {
        let list_state = virtual_list_state(0, ListAlignment::Top, px(256.));
        let controller = VirtualListController::new(list_state.clone());
        Self { list_state, controller, items: Vec::new() }
    }

    fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
        let query = window.use_keyed_state("recipe:search:query", cx, |_, _| {
            SharedString::new_static("")
        });

        let query_value = query.read(cx).to_string().to_lowercase();
        let filtered = if query_value.is_empty() {
            self.items.clone()
        } else {
            self.items
                .iter()
                .filter(|s| s.to_lowercase().contains(&query_value))
                .cloned()
                .collect::<Vec<_>>()
        };

        let last_len = window.use_keyed_state("recipe:search:last-len", cx, |_, _| 0usize);
        if *last_len.read(cx) != filtered.len() {
            self.controller.reset(filtered.len());
            last_len.update(cx, |state, _| *state = filtered.len());
        }

        let controller = self.controller.clone();
        let filtered_for_rows = filtered.clone();
        let items_for_count = self.items.clone();
        let query_for_change = query.clone();

        div()
            .flex()
            .flex_col()
            .gap_3()
            .child(
                search_input()
                    .id(("demo", "search"))
                    .placeholder("Search…")
                    .on_change(move |value, window, cx| {
                        query_for_change.update(cx, |state, _| *state = value);

                        let q = query_for_change.read(cx).to_string().to_lowercase();
                        let next_len = if q.is_empty() {
                            items_for_count.len()
                        } else {
                            items_for_count
                                .iter()
                                .filter(|s| s.to_lowercase().contains(&q))
                                .count()
                        };

                        controller.reset(next_len);
                        window.refresh();
                    }),
            )
            .child(
                virtual_list(self.list_state.clone(), move |ix, _window, _cx| -> AnyElement {
                    let text = filtered_for_rows.get(ix).cloned().unwrap_or_default();
                    virtual_row(("result", ix))
                        .gap_below(px(6.))
                        .child(list_item().content(label(text).strong(true)))
                        .into_any_element()
                }),
            )
    }
}

Notes

  • Prefer stable keys from your data model (id/uuid/path) instead of (\"result\", ix) when rows can reorder.
  • When your filtered list length changes, call controller.reset(...) or splice(...).

Related

  • Design-Virtualization
  • Design-Keyed-State

Clone this wiki locally