List
A scrollable, focusable list. Arrow keys move a cursor through the items, Enter or a click selects one, and long lists scroll to follow the cursor.
use ratcn::{List, ListItem};
let list = List::new([
ListItem::new(Folder::Inbox, "Inbox"),
ListItem::new(Folder::Archive, "Archive"),
ListItem::new(Folder::Settings, "Settings"),
])
.item_focus(
|s: &AppState| s.focused_folder,
|folder, offset| Msg::FolderFocused { folder, offset },
)
.selection(|s: &AppState| s.selected_folder, Msg::FolderSelected);
ctx.render_component("folders", list, area);Items are identified by your own values, not by row index, so sorting or filtering the list keeps the same item selected. item_focus is the cursor and selection is the committed choice — separate, so a user can browse without changing anything.
Multi-selection
Any number of items at once, with checkbox markers. Instead of a selected value you give a predicate: List asks "is this one selected?" per item, so the selection can live in a HashSet, a Vec, or a flag on each record.
List::new(items)
.item_focus(
|s: &AppState| s.focused_topic,
|topic, offset| Msg::TopicFocusChanged { topic, offset },
)
.multi_selection(
|s: &AppState, topic| s.subscribed.contains(topic),
Msg::TopicToggled,
)on_toggle reports the item the user flipped; your update function adds or removes it. Pick one mode — .selection(...) and .multi_selection(...) together will panic.
Custom rows
render_item replaces the default marker-and-label line with anything you can draw. For rows taller than one line, return a Text and set .row_height(...) to match.
List::new(people)
.multi_selection(|s: &AppState, name| s.invited.contains(name), Msg::Toggled)
.row_height(2)
.render_item(move |state: &AppState, row| {
let marker = if row.selected { "[x]" } else { "[ ]" };
Text::from(vec![
Line::from(format!("{marker} {}", row.label)),
Line::from(Span::styled(
format!(" {}", state.title_for(row.label)),
Style::default().add_modifier(Modifier::DIM),
)),
])
})Every item is the same height, which keeps clicking and paging exact. The default markers are ■/□ and ●/○; this demo draws ASCII [x]/[ ] instead. List state foreground and background colors override colors returned by render_item, including colors on individual spans, while modifiers such as bold and italic are preserved. This override applies only to custom rows; the default markers retain the style's selected_marker and unselected_marker colors.
.focus_symbol("> ") adds a marker in front of the cursor row without replacing the row.
Disabled
ListItem::disabled(true) dims one row and skips it for keys and clicks. .disabled(true) on the list disables the whole thing, and Tab skips it.
ListItem::new(Folder::Settings, "Settings").disabled(!state.is_admin)Scrolling
The list scrolls itself to keep the cursor visible. Bind .scroll(...) only when something outside needs the offset — a scrollbar alongside, say. The offset is an item index even when items occupy multiple terminal rows. Unbound, wheel events pass through to whatever encloses the list.
List::new(items).scroll(|s: &AppState| s.scroll, Msg::ScrollChanged)item_focus calls its message constructor with both the target item and the resulting top-item offset. A bound-scroll app must store both in one update:
enum Msg {
ItemFocused { item: ItemId, offset: usize },
ScrollChanged(usize),
}
Msg::ItemFocused { item, offset } => {
state.focused_item = Some(item);
state.scroll = offset;
}This keeps repeated navigation events correct even when several arrive before redraw. If scroll is unbound, ignore the second callback argument.
Styling
Colors come from the theme. .style(...) overrides them, and the closure gets the active theme each render so a derived style follows theme switches. Focus lightens the field backdrop subtly; hover lightens it a little further, so the pointer remains visible when the list already has keyboard focus.
use ratcn::ListStyle;
List::new(items).style(|theme| {
let mut style = ListStyle::from_theme(theme);
style.focused_row_background = theme.accent;
style
})Paint-Only Widget
ListWidget draws a list without focus or events. It is an ordinary Ratatui widget, so it works in a plain Ratatui app with no Ratcn runtime. Rows are pre-rendered Texts and everything is addressed by index. Explicit colors in those Texts are preserved:
use ratatui::text::Text;
use ratcn::ListWidget;
let items = vec![Text::from("Inbox"), Text::from("Archive")];
frame.render_widget(
ListWidget::new(&items)
.offset(scroll_offset)
.focused_row(Some(0))
.selected_rows(&[1])
.disabled_rows(&[false, true])
.focused(list_has_focus)
.hovered(pointer_is_over_list)
.focus_symbol("> ")
.themed(&theme),
area,
);Scrolling is an input: pass the index of the topmost visible item with offset each frame. The widget paints exactly what it is told and never adjusts the value, so your app stays the only scroll policy.
Notes
- Item values must be unique within a list; duplicates panic during declaration.
ListStyle::fallback()is the no-theme palette, for plain ANSI terminals.- Mouse input needs capture enabled in the host — see Mouse input.
Full API
Every method, with binding requirements and edge-case detail: List, ListItem, ListItemState, ListWidget, ListStyle.
See Also
The horizontal counterpart, with the same cursor and selection split: Tabs.