T13a Lists: ListItem, List, SelectableList, VirtualList, InfiniteList, GroupedList, SortableList, SwipeableListItem (review pending: codex 401)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 19:35:28 -04:00
1 parent f41faf5684
commit 2d6404f688
23 files changed
+1820 -14

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
use std::time::Duration;
use ely_gpui_component::{
data_display::Avatar,
lists::{GroupedList, InfiniteList, ListItem, VirtualList},
theme::{ActiveTheme, AvatarSize, Radius},
};
use gpui::{
App, InteractiveElement, IntoElement, ParentElement, SharedString, StatefulInteractiveElement,
Styled, Window, div, px,
};
use crate::ui::{keep, section, set};
fn framed(cx: &App) -> gpui::Div {
let theme = cx.theme();
div()
.w(px(420.))
.h(px(280.))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
}
pub fn virtual_list(cx: &mut App) -> impl IntoElement + use<> {
section(
"VirtualList",
"Ten thousand rows, each as tall as it needs; only those near the view are built.",
cx,
)
.child(
framed(cx).child(
VirtualList::new("long", 10_000, |ix, _, _| {
let row = ListItem::new(("long-row", ix), format!("Row {}", ix + 1));
let row = if ix % 7 == 0 {
row.description("Every seventh row carries a second line, so heights differ.")
} else {
row
};
div().px_1().child(row).into_any_element()
})
.size_full(),
),
)
}
/// Pages of twelve, five pages in all.
const PAGE: usize = 12;
pub fn infinite(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = keep("infinite", || (PAGE, false), window, cx);
let ((rows, loading), more) = (*state.read(cx), state.clone());
section(
"InfiniteList",
"Scroll to the end and the next page comes, a spinner holding its place while it loads. After five pages, the list ends quietly.",
cx,
)
.child(
framed(cx).child(
div().id("infinite-scroll").size_full().overflow_y_scroll().child(
InfiniteList::new("infinite")
.children((0..rows).map(|ix| ListItem::new(("infinite-row", ix), format!("Message {}", ix + 1))))
.loading(loading)
.done(rows >= PAGE * 5)
.on_more(move |_, cx| {
set(&more, (rows, true), cx);
let more = more.clone();
cx.spawn(async move |cx| {
cx.background_executor().timer(Duration::from_millis(700)).await;
cx.update(|cx| set(&more, (rows + PAGE, false), cx)).ok();
})
.detach();
}),
),
),
)
}
const PEOPLE: [&str; 14] = [
"Ada Lovelace",
"Alan Kay",
"Alan Turing",
"Barbara Liskov",
"Brian Kernighan",
"Claude Shannon",
"Dennis Ritchie",
"Donald Knuth",
"Edsger Dijkstra",
"Frances Allen",
"Grace Hopper",
"Katherine Johnson",
"Ken Thompson",
"Margaret Hamilton",
];
pub fn grouped(cx: &mut App) -> impl IntoElement + use<> {
let mut letters: Vec<char> = PEOPLE
.iter()
.filter_map(|name| name.chars().next())
.collect();
letters.dedup();
let list = letters
.into_iter()
.fold(GroupedList::new("people"), |list, letter| {
let rows = PEOPLE
.iter()
.filter(move |name| name.starts_with(letter))
.map(|name| {
div().px_1().child(
ListItem::new(SharedString::from(format!("person-{name}")), *name).leading(
Avatar::new(SharedString::from(format!("face-{name}")), *name)
.size(AvatarSize::Sm),
),
)
});
list.group(letter.to_string(), rows)
});
section(
"GroupedList / SectionList",
"Rows under their letter; each letter pins to the top while its group scrolls past.",
cx,
)
.child(framed(cx).child(list.size_full()))
}
+41
View File
@@ -0,0 +1,41 @@
mod long;
mod rows;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
use super::Page;
use crate::script::Step;
pub const PAGE: Page = Page {
number: 13,
slug: "lists",
title: "Lists & Trees",
summary: "Rows to read, pick, reorder and swipe; lists long enough to need care.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::Rest,
Step::DownAt("pick", 20.0, 52.0),
Step::UpAt("pick", 20.0, 52.0),
Step::Key("shift-down"),
Step::Wait(200),
Step::Shot("selected-range"),
Step::Swipe("swipe-row", -120.0),
Step::Wait(500),
Step::Shot("swiped-open"),
Step::Rest,
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(rows::items(cx))
.child(rows::selectable(window, cx))
.child(rows::sortable(window, cx))
.child(rows::swipeable(window, cx))
.child(long::virtual_list(cx))
.child(long::infinite(window, cx))
.child(long::grouped(cx))
.into_any_element()
}
+176
View File
@@ -0,0 +1,176 @@
use ely_gpui_component::{
data_display::{Avatar, Badge, CountBadge, Tone},
lists::{List, ListItem, SelectableList, SortableList, SwipeAction, SwipeableListItem},
primitives::{Icon, IconName, Severity},
theme::{ActiveTheme, AvatarSize, IconSize, Radius},
typography::Caption,
};
use gpui::{App, ElementId, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{keep, section, set},
};
pub fn items(cx: &mut App) -> impl IntoElement + use<> {
let theme = cx.theme();
let folder = |icon| {
Icon::new(icon)
.size(IconSize::Md)
.color(theme.colors.fg_muted)
};
section(
"List / ListItem",
"Rows with something to lead with, a title and a quieter line, and something at the end. Rows that can be pressed light on hover.",
cx,
)
.child(
div().w(px(480.)).child(
List::new()
.divided()
.child(
ListItem::new("item-grace", "Grace Hopper")
.description("Design review: the cards finally breathe.")
.leading(Avatar::new("item-grace-face", "Grace Hopper").size(AvatarSize::Md))
.trailing("09:41")
.on_click(|_, _, _| log::info!("gallery: opened Grace's note")),
)
.child(
ListItem::new("item-inbox", "Inbox")
.leading(folder(IconName::Inbox))
.trailing(CountBadge::new("item-inbox-count", 12))
.on_click(|_, _, _| log::info!("gallery: opened the inbox")),
)
.child(
ListItem::new("item-drafts", "Drafts")
.description("Two unsent")
.leading(folder(IconName::FileText))
.trailing(Badge::new("Synced").tone(Severity::Success).dot()),
)
.child(
ListItem::new("item-archive", "Archive")
.description("Moving to cold storage")
.leading(folder(IconName::Archive))
.disabled(true),
),
),
)
}
const FILES: [(&str, &str, &str); 5] = [
("brief", "Brief.pdf", "2.4 MB"),
("plan", "Q4 plan.key", "18 MB"),
("notes", "Interview notes.md", "12 KB"),
("budget", "Budget.numbers", "640 KB"),
("logo", "Logo.svg", "8 KB"),
];
pub fn selectable(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let picked = keep(
"pick-selected",
|| vec![SharedString::from("plan")],
window,
cx,
);
let (now, store) = (picked.read(cx).clone(), picked.clone());
let theme = cx.theme();
let list = FILES.iter().enumerate().fold(
SelectableList::new("pick").multiple().selected(now.clone()),
|list, (ix, (key, name, size))| {
let row = ListItem::new(("pick-item", ix), *name)
.leading(
Icon::new(IconName::File)
.size(IconSize::Md)
.color(theme.colors.fg_muted),
)
.trailing(*size);
list.row(*key, row)
},
);
section(
"SelectableList",
"A press picks one; Cmd-press adds or drops one, Shift-press takes a range. With focus, Up and Down move, Shift extends, Space toggles and Cmd-A takes all.",
cx,
)
.child(
probe(
"pick",
div()
.w(px(420.))
.child(list.on_change(move |keys, _, cx| set(&store, keys.to_vec(), cx))),
),
)
.child(Caption::new(format!("{} selected: {}", now.len(), now.join(", "))))
}
pub fn sortable(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
const TRACKS: [&str; 5] = ["Morning light", "Harbour", "Low tide", "Dunes", "Evening"];
let order = keep("sort-order", || TRACKS.to_vec(), window, cx);
let (now, store) = (order.read(cx).clone(), order.clone());
let list = now
.iter()
.enumerate()
.fold(SortableList::new("sort"), |list, (ix, name)| {
list.row(
*name,
ListItem::new(SharedString::from(format!("sort-{name}")), *name)
.trailing(format!("{}", ix + 1)),
)
});
section(
"SortableList / ReorderableList",
"Drag a row to its new place, or focus the list and press Alt with Up or Down. The others glide aside.",
cx,
)
.child(div().w(px(360.)).child(list.on_reorder(move |from, to, _, cx| {
let mut next = store.read(cx).clone();
let track = next.remove(from);
next.insert(to, track);
set(&store, next, cx)
})))
}
pub fn swipeable(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let archived = keep("swipe-archived", || None::<&'static str>, window, cx);
let (last, store) = (*archived.read(cx), archived.clone());
let theme = cx.theme();
let row = |id: &'static str, who: &'static str, what: &'static str| {
let (archive, delete) = (store.clone(), store.clone());
SwipeableListItem::new(
id,
ListItem::new((ElementId::from(id), "item"), who)
.description(what)
.leading(Avatar::new((ElementId::from(id), "face"), who).size(AvatarSize::Md)),
)
.action(SwipeAction::new(
"Archive",
IconName::Archive,
move |_, cx| set(&archive, Some("Archived"), cx),
))
.action(
SwipeAction::new("Delete", IconName::Trash2, move |_, cx| {
set(&delete, Some("Deleted"), cx)
})
.tone(Tone::Danger),
)
};
section(
"SwipeableListItem",
"Swipe a row left with two fingers to show what can be done to it; it snaps open or shut as the fingers lift.",
cx,
)
.child(
div()
.w(px(420.))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.overflow_hidden()
.child(probe(
"swipe-row",
div().w(px(418.)).child(row("swipe-ada", "Ada Lovelace", "The engine notes, annotated")),
))
.child(div().border_t_1().border_color(theme.colors.border).child(row("swipe-alan", "Alan Turing", "On computable numbers"))),
)
.child(Caption::new(last.unwrap_or("Nothing done yet.")))
}
+2
View File
@@ -3,6 +3,7 @@ mod data;
mod feedback;
mod forms;
mod layout;
mod lists;
mod menus;
mod motion;
mod navigation;
@@ -39,6 +40,7 @@ pub const ALL: &[Page] = &[
feedback::PAGE,
motion::PAGE,
data::PAGE,
lists::PAGE,
theme::PAGE,
];