List
A powerful List component that provides a virtualized, searchable list interface with support for sections, headers, footers, selection states, and infinite scrolling. The component is built on a delegate pattern that allows for flexible data management and custom item rendering.
#Import
use gpui_kit::component::list::{List, ListState, ListDelegate, ListItem, ListEvent, ListSeparatorItem};
use gpui_kit::component::IndexPath;// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { ListEntry, List } from "list.slint";use iced::widget::container::Style as ContainerStyle;
use iced::widget::{column, container, mouse_area, row, svg, text, Space};
use iced::{Alignment, Element, Length, Task, Theme};#Usage
#Basic List
// Eight destinations, longer than the box so the rows move under their own
// bar. `on_select` reports the row and the app keeps the selection, which is
// the Slint `selected` / `changed` pair.
let basic_entries = [
ListEntry::new("Inbox").description("12 unread"),
ListEntry::new("Sent").description("On this device"),
ListEntry::new("Archive").description("Last 30 days"),
ListEntry::new("Spam").description("Nothing reported"),
ListEntry::new("Drafts").description("3 unsent"),
ListEntry::new("Trash").description("Empties after 30 days"),
ListEntry::new("Starred").description("8 conversations"),
ListEntry::new("Snoozed").description("Until tomorrow"),
];
let basic = List::new(basic_entries)
.selected(app.basic_selected)
.height(Length::Fixed(BASIC_HEIGHT))
.on_select(Message::SelectBasic);To create a list, you need to implement the ListDelegate trait for your data:
struct MyListDelegate {
items: Vec<String>,
selected_index: Option<IndexPath>,
}
impl ListDelegate for MyListDelegate {
type Item = ListItem;
fn items_count(&self, _section: usize, _cx: &App) -> usize {
self.items.len()
}
fn render_item(
&mut self,
ix: IndexPath,
_window: &mut Window,
_cx: &mut Context<ListState<Self>>,
) -> Option<Self::Item> {
self.items.get(ix.row).map(|item| {
ListItem::new(ix)
.child(Label::new(item.clone()))
.selected(Some(ix) == self.selected_index)
})
}
fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
_window: &mut Window,
cx: &mut Context<ListState<Self>>,
) {
self.selected_index = ix;
cx.notify();
}
}
// Create the list
let delegate = MyListDelegate {
items: vec!["Item 1".into(), "Item 2".into(), "Item 3".into()],
selected_index: None,
};
/// Create a list state.
let state = cx.new(|cx| ListState::new(delegate, window, cx));List {
items: [
{ title: "Inbox", description: "12 unread", icon: "" },
{ title: "Sent", description: "On this device", icon: "" },
{ title: "Archive", description: "Last 30 days", icon: "" },
{ title: "Spam", description: "Nothing reported", icon: "" },
];
selected: 1;
}Now use [List] to render list:
div().child(List::new(&state))#List with Sections
Note: Sections with items_count of 0 will be automatically hidden (no header or footer will be rendered for empty sections).
impl ListDelegate for MyListDelegate {
type Item = ListItem;
fn sections_count(&self, _cx: &App) -> usize {
3 // Number of sections
}
fn items_count(&self, section: usize, _cx: &App) -> usize {
match section {
0 => 5,
1 => 3,
2 => 7,
_ => 0,
}
}
fn render_section_header(
&mut self,
section: usize,
_window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<impl IntoElement> {
let title = match section {
0 => "Section 1",
1 => "Section 2",
2 => "Section 3",
_ => return None,
};
Some(
h_flex()
.px_2()
.py_1()
.gap_2()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(IconName::Folder))
.child(title)
)
}
fn render_section_footer(
&mut self,
section: usize,
_window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<impl IntoElement> {
Some(
div()
.px_2()
.py_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!("End of section {}", section + 1))
)
}
}#List Items with Icons and Actions
fn render_item(
&mut self,
ix: IndexPath,
_window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<Self::Item> {
self.items.get(ix.row).map(|item| {
ListItem::new(ix)
.child(
h_flex()
.items_center()
.gap_2()
.child(Icon::new(IconName::File))
.child(Label::new(item.title.clone()))
)
.suffix(|_, _| {
Button::new("action")
.ghost()
.small()
.icon(IconName::MoreHorizontal)
})
.selected(Some(ix) == self.selected_index)
.on_click(cx.listener(move |this, _, window, cx| {
this.delegate_mut().select_item(ix, window, cx);
}))
})
}List {
items: [
{ title: "Project files", description: "src/ and Cargo.toml", icon: "folder" },
{ title: "Recent", description: "dock.slint, menu.slint", icon: "clock" },
{ title: "Shared with me", description: "3 people", icon: "users" },
{ title: "Trash", description: "Empties after 30 days", icon: "trash" },
];
selected: 0;
}// A leading icon turns a row into a destination. The artwork is the
// application's, taken here from the same Lucide set the Slint icons folder
// carries.
let icon_entries = [
ListEntry::new("Project files")
.description("src/ and Cargo.toml")
.icon(entry_icon(FOLDER)),
ListEntry::new("Recent")
.description("dock.slint, menu.slint")
.icon(entry_icon(CLOCK)),
ListEntry::new("Shared with me")
.description("3 people")
.icon(entry_icon(USERS)),
ListEntry::new("Trash")
.description("Empties after 30 days")
.icon(entry_icon(TRASH)),
];
let icons = List::new(icon_entries)
.selected(app.icons_selected)
.on_select(Message::SelectIcons);#List with Search
// A field above the list is the usual shape. The field itself is an `Input`
// in GPUI; iced has one too, but the Slint example mocks it so the focus stays
// on the list, and this port keeps the mock rather than wiring an input whose
// query filters nothing.
let search_entries = [
ListEntry::new("Button").description("Push button"),
ListEntry::new("Badge").description("Status label"),
ListEntry::new("Banner").description("Page notice"),
];
let search_field = row![
search_glyph(),
text("Search components…")
.size(theme::FONT_SIZE_SM)
.style(|theme: &Theme| text::Style {
color: Some(Tokens::of(theme).muted_foreground),
}),
]
.spacing(theme::SPACING_SM)
.padding(theme::SPACING_SM)
.align_y(Alignment::Center);
let search = container(
column![
search_field,
rule(),
List::new(search_entries)
.selected(app.search_selected)
.on_select(Message::SelectSearch),
]
.spacing(theme::SPACING_SM),
)
.style(field_surface);The list automatically includes a search input by default. Implement perform_search to handle queries:
And you should use searchable(true) when creating the ListState to show search input.
impl ListDelegate for MyListDelegate {
fn perform_search(
&mut self,
query: &str,
_window: &mut Window,
_cx: &mut Context<ListState<Self>>,
) -> Task<()> {
// Filter items based on query
self.filtered_items = self.all_items
.iter()
.filter(|item| item.to_lowercase().contains(&query.to_lowercase()))
.cloned()
.collect();
Task::ready(())
}
}
let state = cx.new(|cx| ListState::new(delegate, window, cx).searchable(true));
List::new(&state)Rectangle {
horizontal-stretch: 1;
border-radius: Theme.radius-md;
border-width: 1px;
border-color: Theme.border;
background: Theme.background;
VerticalLayout {
spacing: 8px;
HorizontalLayout {
padding: 8px;
spacing: 8px;
alignment: center;
Icon {
name: "search";
size: 14px;
color: Theme.muted-foreground;
}
Text {
horizontal-stretch: 1;
text: "Search components…";
color: Theme.muted-foreground;
font-size: Theme.font-size-sm;
vertical-alignment: center;
}
}
Rectangle {
height: 1px;
background: Theme.border;
}
List {
items: [
{ title: "Button", description: "Push button", icon: "" },
{ title: "Badge", description: "Status label", icon: "" },
{ title: "Banner", description: "Page notice", icon: "" },
];
selected: 0;
}
}
}#List with Loading State
impl ListDelegate for MyListDelegate {
fn loading(&self, _cx: &App) -> bool {
self.is_loading
}
fn render_loading(
&mut self,
_window: &mut Window,
_cx: &mut Context<ListState<Self>>,
) -> impl IntoElement {
// Custom loading view
v_flex()
.justify_center()
.items_center()
.py_4()
.child(Skeleton::new().h_4().w_full())
.child(Skeleton::new().h_4().w_3_4())
}
}#Infinite Scrolling
impl ListDelegate for MyListDelegate {
fn has_more(&self, _cx: &App) -> bool {
self.has_more_data
}
fn load_more_threshold(&self) -> usize {
20 // Trigger when 20 items from bottom
}
fn load_more(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {
if self.is_loading {
return;
}
self.is_loading = true;
cx.spawn_in(window, async move |view, window| {
// Simulate API call
Timer::after(Duration::from_secs(1)).await;
view.update_in(window, |view, _, cx| {
// Add more items
view.delegate_mut().load_more_items();
view.delegate_mut().is_loading = false;
cx.notify();
});
}).detach();
}
}#List Events
// Subscribe to list events
let _subscription = cx.subscribe(&state, |_, _, event: &ListEvent, _| {
match event {
ListEvent::Select(ix) => {
println!("Item selected at: {:?}", ix);
}
ListEvent::Confirm(ix) => {
println!("Item confirmed at: {:?}", ix);
}
ListEvent::Cancel => {
println!("Selection cancelled");
}
}
});#Different Item Styles
// Basic item with hover effect
ListItem::new(ix)
.child(Label::new("Basic Item"))
.selected(is_selected)
// Item with check icon
ListItem::new(ix)
.child(Label::new("Checkable Item"))
.check_icon(IconName::Check)
.confirmed(is_confirmed)
// Disabled item
ListItem::new(ix)
.child(Label::new("Disabled Item"))
.disabled(true)
// Separator item
ListSeparatorItem::new()
.child(
div()
.h_px()
.w_full()
.bg(cx.theme().border)
)#Drag and Drop Reordering
ListItem implements GPUI’s InteractiveElement and StatefulInteractiveElement
traits, so all native interaction APIs such as on_drag, on_drop, drag_over
and on_hover are directly available:
#[derive(Clone)]
struct DragItem {
ix: IndexPath,
name: SharedString,
}
impl Render for DragItem {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// The preview element that follows the cursor while dragging.
div()
.px_2()
.py_1()
.bg(cx.theme().accent)
.text_color(cx.theme().accent_foreground)
.rounded(cx.theme().radius)
.child(self.name.clone())
}
}
// In `render_item` of your `ListDelegate`:
ListItem::new(ix)
.child(Label::new(item.name.clone()))
.on_drag(DragItem { ix, name: item.name.clone() }, |drag, _, _, cx| {
cx.new(|_| drag.clone())
})
.drag_over::<DragItem>(|style, _, _, cx| style.bg(cx.theme().drop_target))
.on_drop(cx.listener(move |this, drag: &DragItem, _, cx| {
this.delegate_mut().move_item(drag.ix, ix);
cx.notify();
}))#Custom Empty State
impl ListDelegate for MyListDelegate {
fn render_empty(&mut self, _window: &mut Window, cx: &mut Context<ListState<Self>>) -> impl IntoElement {
v_flex()
.size_full()
.justify_center()
.items_center()
.gap_2()
.child(Icon::new(IconName::Search).size_16().text_color(cx.theme().muted_foreground))
.child(
Label::new("No items found")
.text_color(cx.theme().muted_foreground)
)
.child(
Label::new("Try adjusting your search terms")
.text_sm()
.text_color(cx.theme().muted_foreground.opacity(0.7))
)
}
}#File Browser List
struct FileBrowserDelegate {
files: Vec<FileInfo>,
selected: Option<IndexPath>,
}
#[derive(Clone)]
struct FileInfo {
name: String,
is_directory: bool,
size: Option<u64>,
}
impl ListDelegate for FileBrowserDelegate {
type Item = ListItem;
fn render_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context<ListState<Self>>) -> Option<Self::Item> {
self.files.get(ix.row).map(|file| {
let icon = if file.is_directory {
IconName::Folder
} else {
IconName::File
};
ListItem::new(ix)
.child(
h_flex()
.items_center()
.justify_between()
.w_full()
.child(
h_flex()
.items_center()
.gap_2()
.child(Icon::new(icon))
.child(Label::new(file.name.clone()))
)
.when_some(file.size, |this, size| {
this.child(
Label::new(format_size(size))
.text_sm()
.text_color(cx.theme().muted_foreground)
)
})
)
.selected(Some(ix) == self.selected)
})
}
}#Contact List with Sections
struct ContactListDelegate {
contacts_by_letter: BTreeMap<char, Vec<Contact>>,
selected: Option<IndexPath>,
}
impl ListDelegate for ContactListDelegate {
type Item = ListItem;
fn sections_count(&self, _cx: &App) -> usize {
self.contacts_by_letter.len()
}
fn render_section_header(&mut self, section: usize, _window: &mut Window, cx: &mut Context<ListState<Self>>) -> Option<impl IntoElement> {
let letter = self.contacts_by_letter.keys().nth(section)?;
Some(
div()
.px_3()
.py_2()
.bg(cx.theme().background)
.border_b_1()
.border_color(cx.theme().border)
.child(
Label::new(letter.to_string())
.text_lg()
.text_color(cx.theme().accent_foreground)
.font_weight(FontWeight::BOLD)
)
)
}
}#Configuration Options
#List Configuration
List::new(&state)
.max_h(px(400.)) // Set maximum height
.scrollbar_visible(false) // Hide scrollbar
.paddings(Edges::all(px(8.))) // Set internal padding#Scrolling Control
// Scroll to specific item
state.update(cx, |state, cx| {
state.scroll_to_item(
IndexPath::new(0).section(1), // Row 0 of section 1
ScrollStrategy::Center,
window,
cx,
);
});
// Scroll to selected item
state.update(cx, |state, cx| {
state.scroll_to_selected_item(window, cx);
});
// Set selected index without scrolling
state.update(cx, |state, cx| {
state.set_selected_index(Some(IndexPath::new(5)), window, cx);
});