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

+3
View File
@@ -82,6 +82,9 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `ScrollHandle::scroll_to_item` runs in the container's prepaint before its bounds are stored, so a first-frame call misfires. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). Never poll layout with `request_animation_frame`.
- gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view.
- Machine-read marks, QR codes and barcodes, paint `ink` on `paper`: dark on light in both themes, so cameras read them.
- A layer over pressable content that must still pass scrolls to its ancestors uses `block_mouse_except_scroll`; `occlude` hides them from scrolls too.
- Trackpad momentum arrives as `TouchPhase::Moved` after `Ended`, so a gesture acts only between `Started` and `Ended` (`lists::SwipeableListItem`).
- Quartz scroll events carry no window. The harness hands them to the window itself, placed in window points (`Step::Swipe`).
- Reduced motion shortens animations to 1ms on the wall clock. Tests sleep past it between refreshes to see where one ends.
- The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots.
- An absolute element with no insets sits where it would flow: after the content in a block, inside the padding in a flex box. A canvas that measures its parent pins itself with `.top_0().left_0()`.
+4 -1
View File
@@ -46,7 +46,9 @@ Per-component lines live in `tasks/`. Tags there:
- [ ] T12b Data Display · Records
- [ ] T12c Data Display · Media
- [ ] T12d Data Display · Codes & measures
- [ ] T13 Lists & Trees
- [ ] T13a Lists & Trees · Lists
- [ ] T13b Lists & Trees · Trees
- [ ] T13c Lists & Trees · Files
- [ ] T14 Tables
- [ ] T15 Charts
- [ ] T16a Finance · Market charts
@@ -186,3 +188,4 @@ Per-component lines live in `tasks/`. Tags there:
| T12b | 1 | — | pending: codex still logged out |
| T12c | 1 | — | pending: codex still logged out |
| T12d | 1 | — | pending: codex still logged out |
| T13a | 1 | — | pending: codex still logged out |
+1
View File
@@ -3,6 +3,7 @@ mod pages;
mod probe;
mod script;
mod shell;
mod swipe;
mod ui;
use std::path::PathBuf;
+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,
];
+15
View File
@@ -7,6 +7,7 @@ use crate::{
capture::{number, popup_number, snapshot},
probe::{Opened, Probes},
shell::Gallery,
swipe::{BEGAN, CHANGED, ENDED, swipe},
};
/// One scripted input, aimed at a probe by key.
@@ -24,6 +25,8 @@ pub enum Step {
RightAt(&'static str, f32, f32),
/// Drags from the last point to an offset, in small steps.
DragTo(&'static str, f32, f32),
/// Swipes two fingers sideways across the probe's middle, as a trackpad does: begin, move, end.
Swipe(&'static str, f32),
UpAt(&'static str, f32, f32),
Key(&'static str),
/// Types text into whatever holds focus, one key at a time.
@@ -117,6 +120,18 @@ pub async fn play(
send(window, Mouse::RightDown, at, cx)?;
send(window, Mouse::RightUp, at, cx)?;
}
Step::Swipe(key, across) => {
let at = target_bounds(window, key, cx).await?.center();
send(window, Mouse::Move, at, cx)?;
swipe(window, at, 0.0, BEGAN, cx)?;
for _ in 0..DRAG_STEPS {
swipe(window, at, across / DRAG_STEPS as f32, CHANGED, cx)?;
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
}
swipe(window, at, 0.0, ENDED, cx)?;
}
Step::DragTo(key, x, y) => {
let goal = target_bounds(window, key, cx).await?.origin + point(px(x), px(y));
for step in 1..=DRAG_STEPS {
+91
View File
@@ -0,0 +1,91 @@
use anyhow::Result;
use gpui::{AsyncApp, Pixels, Point, WindowHandle};
use crate::{capture::number, shell::Gallery};
/// A scroll's phase, as AppKit numbers them.
pub const BEGAN: i64 = 1;
pub const CHANGED: i64 = 2;
pub const ENDED: i64 = 4;
pub fn swipe(
window: WindowHandle<Gallery>,
at: Point<Pixels>,
across: f32,
phase: i64,
cx: &mut AsyncApp,
) -> Result<()> {
let (height, number) = window.update(cx, |_, window, _| {
(window.viewport_size().height, number(window))
})?;
post_scroll(
f64::from(at.x),
f64::from(height - at.y),
across,
phase,
number?,
)
}
/// Sends a real trackpad scroll to the window, sideways by `across` points, with its phase. Quartz scrolls carry no window, so AppKit reads their location as window points and the window takes them directly.
#[cfg(target_os = "macos")]
fn post_scroll(x: f64, y: f64, across: f32, phase: i64, number: u32) -> Result<()> {
use cocoa::{
appkit::NSApp,
base::{id, nil},
foundation::NSRect,
};
use objc::{class, msg_send, sel, sel_impl};
type Event = *mut std::ffi::c_void;
#[repr(C)]
struct Spot {
x: f64,
y: f64,
}
unsafe extern "C" {
fn CGEventCreateScrollWheelEvent2(
source: *const std::ffi::c_void,
units: u32,
count: u32,
wheel1: i32,
wheel2: i32,
wheel3: i32,
) -> Event;
fn CGEventSetIntegerValueField(event: Event, field: u32, value: i64);
fn CGEventSetLocation(event: Event, at: Spot);
fn CFRelease(object: *const std::ffi::c_void);
}
const PIXELS: u32 = 0;
const CONTINUOUS: u32 = 88;
const PHASE: u32 = 99;
unsafe {
let window: id = msg_send![NSApp(), windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(window != nil, "no window numbered {number}");
let screens: id = msg_send![class!(NSScreen), screens];
let primary: id = msg_send![screens, objectAtIndex: 0usize];
let frame: NSRect = msg_send![primary, frame];
let event = CGEventCreateScrollWheelEvent2(
std::ptr::null(),
PIXELS,
2,
0,
across.round() as i32,
0,
);
anyhow::ensure!(!event.is_null(), "Quartz refused a scroll event");
CGEventSetLocation(
event,
Spot {
x,
y: frame.size.height - y,
},
);
CGEventSetIntegerValueField(event, CONTINUOUS, 1);
CGEventSetIntegerValueField(event, PHASE, phase);
let scroll: id = msg_send![class!(NSEvent), eventWithCGEvent: event];
CFRelease(event);
anyhow::ensure!(scroll != nil, "AppKit refused a scroll event");
let _: () = msg_send![window, sendEvent: scroll];
}
Ok(())
}
+1 -1
View File
@@ -37,7 +37,7 @@ impl From<Severity> for Tone {
impl Tone {
/// The fill, then the text on it.
pub(super) fn colors(self, colors: &Palette) -> (Hsla, Hsla) {
pub(crate) fn colors(self, colors: &Palette) -> (Hsla, Hsla) {
let severity = |severity: Severity| (severity.subtle(colors), severity.color(colors));
match self {
Self::Neutral => (colors.hover, colors.fg_muted),
+1
View File
@@ -5,6 +5,7 @@ pub mod data_display;
pub mod feedback;
pub mod forms;
pub mod layout;
pub mod lists;
pub mod menus;
pub mod motion;
pub mod navigation;
+85
View File
@@ -0,0 +1,85 @@
use gpui::{
AnyElement, App, Div, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
Window, div,
};
use crate::{
layout::StickyHeader,
theme::{ActiveTheme, TextSize},
};
/// Rows under group headers; each header pins to the top while its group scrolls past. Give it a height.
#[derive(IntoElement)]
pub struct GroupedList {
id: ElementId,
base: Div,
groups: Vec<(SharedString, Vec<AnyElement>)>,
}
impl GroupedList {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
groups: Vec::new(),
}
}
pub fn group(
mut self,
title: impl Into<SharedString>,
rows: impl IntoIterator<Item = impl IntoElement>,
) -> Self {
let rows = rows
.into_iter()
.map(IntoElement::into_any_element)
.collect();
self.groups.push((title.into(), rows));
self
}
}
impl Styled for GroupedList {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for GroupedList {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let handle = window
.use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new())
.read(cx)
.clone();
let id = self.id.clone();
self.base
.id(self.id)
.overflow_y_scroll()
.track_scroll(&handle)
.children(
self.groups
.into_iter()
.enumerate()
.map(|(ix, (title, rows))| {
StickyHeader::new(
(id.clone(), format!("group-{ix}")),
&handle,
move |_, cx| {
let theme = cx.theme();
div()
.px_3()
.pt_3()
.pb_1()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::SEMIBOLD)
.text_color(theme.colors.fg_muted)
.child(title.clone())
.into_any_element()
},
)
.children(rows)
}),
)
}
}
+189
View File
@@ -0,0 +1,189 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*, relative,
};
use smallvec::SmallVec;
use crate::{
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::LEADING,
};
type OnClick = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
/// One row of a list: something to lead with, a title and a line under it, and something at the end. It lights on hover when it can be pressed, and stays lit when selected.
#[derive(IntoElement)]
pub struct ListItem {
id: ElementId,
title: SharedString,
description: Option<SharedString>,
leading: Option<AnyElement>,
trailing: Option<AnyElement>,
selected: bool,
current: bool,
disabled: bool,
on_click: Option<OnClick>,
}
impl ListItem {
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: title.into(),
description: None,
leading: None,
trailing: None,
selected: false,
current: false,
disabled: false,
on_click: None,
}
}
/// A second line, quieter than the title.
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
self.description = Some(text.into());
self
}
/// An icon or an avatar before the text.
pub fn leading(mut self, element: impl IntoElement) -> Self {
self.leading = Some(element.into_any_element());
self
}
/// A time, a count or an action after the text.
pub fn trailing(mut self, element: impl IntoElement) -> Self {
self.trailing = Some(element.into_any_element());
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Rings the row the keyboard is on, in a list that has focus.
pub fn current(mut self, current: bool) -> Self {
self.current = current;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Makes the row pressable; the press keeps focus where it is.
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ListItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let line = |text: SharedString| div().truncate().line_height(relative(LEADING)).child(text);
let pressable = self.on_click.is_some() && !self.disabled;
div()
.id(self.id)
.flex()
.items_center()
.gap_3()
.px_3()
.py_1p5()
.min_h(theme.control_height(ControlSize::Lg))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if self.current {
colors.focus
} else {
gpui::transparent_black()
})
.when(self.selected, |row| row.bg(colors.active))
.when(self.disabled, |row| row.opacity(0.5))
.when_some(self.on_click.filter(|_| pressable), |row, on_click| {
row.cursor_pointer()
.when(!self.selected, |row| {
row.hover(|style| style.bg(colors.hover))
})
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |event, window, cx| on_click(event, window, cx))
})
.children(self.leading.map(|leading| div().flex_none().child(leading)))
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
line(self.title)
.text_size(theme.text_size(TextSize::Base))
.text_color(colors.fg),
)
.children(self.description.map(|text| {
line(text)
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
})),
)
.children(self.trailing.map(|trailing| {
div()
.flex_none()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_subtle)
.child(trailing)
}))
}
}
/// Rows in a column; `divided` draws a hairline between them.
#[derive(IntoElement, Default)]
pub struct List {
rows: SmallVec<[AnyElement; 8]>,
divided: bool,
}
impl List {
pub fn new() -> Self {
Self::default()
}
pub fn divided(mut self) -> Self {
self.divided = true;
self
}
}
impl ParentElement for List {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.rows.extend(elements);
}
}
impl RenderOnce for List {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let border = cx.theme().colors.border;
let divided = self.divided;
div()
.flex()
.flex_col()
.when(!divided, |list| list.gap_0p5())
.children(self.rows.into_iter().enumerate().map(move |(ix, row)| {
div()
.when(divided && ix > 0, |row| {
row.border_t_1().border_color(border)
})
.child(row)
}))
}
}
+145
View File
@@ -0,0 +1,145 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Div, ElementId, IntoElement, ListAlignment, ListState, ParentElement,
RenderOnce, StyleRefinement, Styled, Window, div, list,
};
use smallvec::SmallVec;
use crate::{motion::Spinner, primitives::IntersectionObserver, theme::ActiveTheme};
type Row = Rc<dyn Fn(usize, &mut Window, &mut App) -> AnyElement>;
/// A long list that builds only the rows in view, each as tall as it needs. `row(ix)` builds row `ix`. Rows added or dropped at the end keep the scroll. Give it a height.
#[derive(IntoElement)]
pub struct VirtualList {
id: ElementId,
base: Div,
count: usize,
row: Row,
}
impl VirtualList {
pub fn new(
id: impl Into<ElementId>,
count: usize,
row: impl Fn(usize, &mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
Self {
id: id.into(),
base: div(),
count,
row: Rc::new(row),
}
}
}
impl Styled for VirtualList {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for VirtualList {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (count, overdraw) = (
self.count,
cx.theme().list_overdraw().to_pixels(window.rem_size()),
);
let state = window
.use_keyed_state(self.id.clone(), cx, |_, _| {
ListState::new(count, ListAlignment::Top, overdraw)
})
.read(cx)
.clone();
let known = state.item_count();
if known != count {
log::debug!("virtual list {:?}: {known} rows to {count}", self.id);
if count > known {
state.splice(known..known, count - known);
} else {
state.splice(count..known, 0);
}
}
let row = self.row;
self.base
.child(list(state, move |ix, window, cx| row(ix, window, cx)).size_full())
}
}
type OnMore = Rc<dyn Fn(&mut Window, &mut App)>;
/// Rows that ask for more when their end comes into view: `on_more` runs once each time it arrives, a spinner shows while `loading`, and `done` ends the list quietly. Put it in a scrolling box.
#[derive(IntoElement)]
pub struct InfiniteList {
id: ElementId,
rows: SmallVec<[AnyElement; 8]>,
loading: bool,
done: bool,
on_more: Option<OnMore>,
}
impl InfiniteList {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
rows: SmallVec::new(),
loading: false,
done: false,
on_more: None,
}
}
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
/// Nothing more to load.
pub fn done(mut self, done: bool) -> Self {
self.done = done;
self
}
pub fn on_more(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_more = Some(Rc::new(handler));
self
}
}
impl ParentElement for InfiniteList {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.rows.extend(elements);
}
}
impl RenderOnce for InfiniteList {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let count = self.rows.len();
let end = match (self.done, self.loading) {
(true, _) => None,
(false, true) => Some(
div()
.flex()
.justify_center()
.py_3()
.child(Spinner::new((self.id.clone(), "spinner")))
.into_any_element(),
),
(false, false) => {
let (id, on_more) = (self.id.clone(), self.on_more);
Some(
IntersectionObserver::new((self.id, format!("end-{count}")), move |seen, window, cx| {
if seen && let Some(on_more) = &on_more {
log::info!("infinite list {id:?}: the end came into view after {count} rows");
on_more(window, cx);
}
})
.h_1()
.into_any_element(),
)
}
};
div().flex().flex_col().children(self.rows).children(end)
}
}
+15
View File
@@ -0,0 +1,15 @@
mod grouped;
mod item;
mod long;
mod select;
mod sortable;
mod swipe;
#[cfg(test)]
mod tests;
pub use grouped::GroupedList;
pub use item::{List, ListItem};
pub use long::{InfiniteList, VirtualList};
pub use select::SelectableList;
pub use sortable::SortableList;
pub use swipe::{SwipeAction, SwipeableListItem};
+236
View File
@@ -0,0 +1,236 @@
use std::rc::Rc;
use gpui::{
App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, ScrollHandle,
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
};
use super::ListItem;
use crate::primitives::tab_stop;
/// How a press or a key picks rows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pick {
One,
Toggle,
Range,
All,
}
/// The selection after picking row `at`, a range reaching back to `anchor`; in row order.
fn picked(
keys: &[SharedString],
selected: &[SharedString],
anchor: usize,
at: usize,
pick: Pick,
) -> Vec<SharedString> {
let (low, high) = (anchor.min(at), anchor.max(at));
keys.iter()
.enumerate()
.filter(|(ix, key)| match pick {
Pick::One => *ix == at,
Pick::Toggle => selected.contains(key) != (*ix == at),
Pick::Range => (low..=high).contains(ix),
Pick::All => true,
})
.map(|(_, key)| key.clone())
.collect()
}
/// The row the keyboard is on, where a Shift range starts, and the scroll that follows them.
#[derive(Default)]
struct Cursor {
at: usize,
anchor: usize,
scroll: ScrollHandle,
}
type OnSelect = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
type Picker = Rc<dyn Fn(usize, Pick, &mut Window, &mut App)>;
/// Rows you select. A press picks one; with `multiple`, Cmd-press adds or drops one and Shift-press takes the range from the last pick. Up and Down move, Shift with them extends, Space toggles, Cmd-A takes all.
#[derive(IntoElement)]
pub struct SelectableList {
id: ElementId,
base: Div,
rows: Vec<(SharedString, ListItem)>,
selected: Vec<SharedString>,
multiple: bool,
on_change: Option<OnSelect>,
}
impl SelectableList {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
rows: Vec::new(),
selected: Vec::new(),
multiple: false,
on_change: None,
}
}
/// A row under `key`, which the selection names it by.
pub fn row(mut self, key: impl Into<SharedString>, item: ListItem) -> Self {
self.rows.push((key.into(), item));
self
}
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.selected = keys.into_iter().map(Into::into).collect();
self
}
pub fn multiple(mut self) -> Self {
self.multiple = true;
self
}
/// Gets the keys now selected, in row order.
pub fn on_change(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl Styled for SelectableList {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for SelectableList {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let count = self.rows.len();
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window);
let cursor =
window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| Cursor::default());
let (at, scroll) = {
let cursor = cursor.read(cx);
(
cursor.at.min(count.saturating_sub(1)),
cursor.scroll.clone(),
)
};
let keys: Rc<[SharedString]> = self.rows.iter().map(|(key, _)| key.clone()).collect();
let selected = self.selected;
let multiple = self.multiple;
let pick: Picker = {
let (id, keys, selected, cursor, on_change) = (
self.id.clone(),
keys.clone(),
selected.clone(),
cursor.clone(),
self.on_change,
);
Rc::new(move |ix, pick, window, cx| {
let pick = match (multiple, pick) {
(true, pick) => pick,
(false, Pick::All) => return,
(false, _) => Pick::One,
};
let next = picked(&keys, &selected, cursor.read(cx).anchor, ix, pick);
cursor.update(cx, |cursor, cx| {
cursor.at = ix;
if pick != Pick::Range {
cursor.anchor = ix;
}
cursor.scroll.scroll_to_item(ix);
cx.notify();
});
log::info!("selectable list {id:?}: {next:?}");
if let Some(on_change) = &on_change {
on_change(&next, window, cx);
}
})
};
let rows: Vec<_> = self
.rows
.into_iter()
.enumerate()
.map(|(ix, (key, item))| {
let (pick, focus) = (pick.clone(), focus.clone());
item.selected(selected.contains(&key))
.current(focused && ix == at)
.on_click(move |event, window, cx| {
let held = event.modifiers();
let how = match (held.shift, held.platform) {
(true, _) => Pick::Range,
(false, true) => Pick::Toggle,
_ => Pick::One,
};
window.focus(&focus);
pick(ix, how, window, cx);
})
})
.collect();
self.base
.id(self.id)
.track_focus(&focus)
.track_scroll(&scroll)
.overflow_y_scroll()
.flex()
.flex_col()
.gap_0p5()
.on_key_down(move |event, window, cx| {
if count == 0 {
return;
}
let held = &event.keystroke.modifiers;
let at = cursor.read(cx).at.min(count - 1);
let extend = if held.shift { Pick::Range } else { Pick::One };
let (to, how) = match event.keystroke.key.as_str() {
"down" => ((at + 1).min(count - 1), extend),
"up" => (at.saturating_sub(1), extend),
"home" => (0, extend),
"end" => (count - 1, extend),
"space" => (at, Pick::Toggle),
"a" if held.platform => (at, Pick::All),
_ => return,
};
cx.stop_propagation();
pick(to, how, window, cx);
})
.children(rows)
}
}
#[cfg(test)]
mod tests {
use gpui::SharedString;
use super::{Pick, picked};
fn keys(list: &[&'static str]) -> Vec<SharedString> {
list.iter().map(|key| SharedString::from(*key)).collect()
}
#[test]
fn picks_one_toggle_a_range_or_all_in_row_order() {
let rows = keys(&["a", "b", "c", "d"]);
assert_eq!(picked(&rows, &keys(&["a"]), 0, 2, Pick::One), keys(&["c"]));
assert_eq!(
picked(&rows, &keys(&["c", "a"]), 0, 1, Pick::Toggle),
keys(&["a", "b", "c"])
);
assert_eq!(
picked(&rows, &keys(&["a", "c"]), 0, 0, Pick::Toggle),
keys(&["c"])
);
assert_eq!(
picked(&rows, &keys(&["a"]), 1, 3, Pick::Range),
keys(&["b", "c", "d"])
);
assert_eq!(
picked(&rows, &keys(&[]), 3, 1, Pick::Range),
keys(&["b", "c", "d"])
);
assert_eq!(picked(&rows, &keys(&["b"]), 0, 0, Pick::All), rows);
}
}
+150
View File
@@ -0,0 +1,150 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
SharedString, Styled, Window, div,
};
use super::ListItem;
use crate::{
motion::Reorder,
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, IconSize},
};
type OnReorder = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
/// Where the keyboard moves row `at` of `count`, or None past either end.
fn nudged(at: usize, count: usize, up: bool) -> Option<usize> {
if up {
at.checked_sub(1)
} else {
(at + 1 < count).then_some(at + 1)
}
}
/// Rows you reorder: drag one, or with the list focused press Alt with Up or Down to move the current row. `on_reorder(from, to)` asks the owner to move row `from` to place `to`.
#[derive(IntoElement)]
pub struct SortableList {
id: ElementId,
rows: Vec<(SharedString, ListItem)>,
on_reorder: Option<OnReorder>,
}
impl SortableList {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
rows: Vec::new(),
on_reorder: None,
}
}
/// A row under `key`, which follows it as it moves.
pub fn row(mut self, key: impl Into<SharedString>, item: ListItem) -> Self {
self.rows.push((key.into(), item));
self
}
pub fn on_reorder(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_reorder = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SortableList {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let count = self.rows.len();
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window);
let cursor = window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| 0usize);
let at = (*cursor.read(cx)).min(count.saturating_sub(1));
let reorder: OnReorder = {
let (id, cursor, on_reorder) = (self.id.clone(), cursor.clone(), self.on_reorder);
Rc::new(move |from, to, window, cx| {
log::info!("sortable list {id:?}: row {from} to {to}");
cursor.update(cx, |cursor, cx| {
*cursor = to;
cx.notify();
});
if let Some(on_reorder) = &on_reorder {
on_reorder(from, to, window, cx);
}
})
};
let theme = cx.theme();
let grip = theme.colors.fg_subtle;
let dragged = reorder.clone();
let list = self.rows.into_iter().enumerate().fold(
Reorder::new((self.id.clone(), "rows"))
.on_reorder(move |from, to, window, cx| dragged(from, to, window, cx)),
|list, (ix, (key, item))| {
let pointed = cursor.clone();
list.row(
key,
div()
.flex()
.items_center()
.gap_1()
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
pointed.update(cx, |cursor, cx| {
*cursor = ix;
cx.notify();
})
})
.child(
Icon::new(IconName::GripVertical)
.size(IconSize::Sm)
.color(grip),
)
.child(
div()
.flex_1()
.min_w_0()
.child(item.current(focused && ix == at)),
),
)
},
);
div()
.id(self.id)
.track_focus(&focus)
.on_key_down(move |event, window, cx| {
let up = match event.keystroke.key.as_str() {
"up" => true,
"down" => false,
_ => return,
};
cx.stop_propagation();
let at = (*cursor.read(cx)).min(count.saturating_sub(1));
let Some(to) = nudged(at, count, up) else {
return;
};
if event.keystroke.modifiers.alt {
reorder(at, to, window, cx);
} else {
cursor.update(cx, |cursor, cx| {
*cursor = to;
cx.notify();
});
}
})
.child(list)
}
}
#[cfg(test)]
mod tests {
use super::nudged;
#[test]
fn a_nudge_stops_at_either_end() {
assert_eq!(nudged(0, 3, true), None);
assert_eq!(nudged(1, 3, true), Some(0));
assert_eq!(nudged(1, 3, false), Some(2));
assert_eq!(nudged(2, 3, false), None);
}
}
+238
View File
@@ -0,0 +1,238 @@
use std::{rc::Rc, time::Instant};
use gpui::{
AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
Pixels, RenderOnce, ScrollDelta, SharedString, StatefulInteractiveElement, Styled, TouchPhase,
Window, div, prelude::*,
};
use crate::{
data_display::Tone,
forms::Run,
motion,
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, TextSize},
};
/// One action behind a swiped row.
pub struct SwipeAction {
label: SharedString,
icon: IconName,
tone: Tone,
run: Run,
}
impl SwipeAction {
pub fn new(
label: impl Into<SharedString>,
icon: IconName,
run: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
label: label.into(),
icon,
tone: Tone::Neutral,
run: Rc::new(run),
}
}
pub fn tone(mut self, tone: impl Into<Tone>) -> Self {
self.tone = tone.into();
self
}
}
/// Where the row rests or follows the fingers, whether they are down and on which axis, and the glide to rest.
#[derive(Default)]
struct Swipe {
offset: Pixels,
fingers: bool,
across: Option<bool>,
from: Pixels,
since: Option<Instant>,
}
impl Swipe {
fn now(&self, length: std::time::Duration) -> Pixels {
let Some(since) = self.since else {
return self.offset;
};
let share = (since.elapsed().as_secs_f32() / length.as_secs_f32()).min(1.0);
self.from + (self.offset - self.from) * motion::ease_out_cubic(share)
}
fn settle(&mut self, to: Pixels, length: std::time::Duration) {
self.from = self.now(length);
self.offset = to;
self.since = Some(Instant::now());
}
}
/// Where a released row rests: open past half its reach, shut before.
fn rest(offset: Pixels, reach: Pixels) -> Pixels {
if offset < reach * -0.5 {
-reach
} else {
Pixels::ZERO
}
}
/// A row that slides left under a two-finger swipe to show the actions behind it, and snaps open or shut when the fingers lift. A press on the open row, or on an action, shuts it.
#[derive(IntoElement)]
pub struct SwipeableListItem {
id: ElementId,
item: AnyElement,
actions: Vec<SwipeAction>,
}
impl SwipeableListItem {
pub fn new(id: impl Into<ElementId>, item: impl IntoElement) -> Self {
Self {
id: id.into(),
item: item.into_any_element(),
actions: Vec::new(),
}
}
pub fn action(mut self, action: SwipeAction) -> Self {
self.actions.push(action);
self
}
}
impl RenderOnce for SwipeableListItem {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let swipe = window.use_keyed_state((self.id.clone(), "swipe"), cx, |_, _| Swipe::default());
let length = motion::duration(motion::BASE, cx);
let theme = cx.theme();
let colors = &theme.colors;
let wide = theme.swipe_action();
let reach = wide.to_pixels(window.rem_size()) * self.actions.len() as f32;
let (at, settling) = {
let swipe = swipe.read(cx);
let settling = swipe.since.is_some_and(|since| since.elapsed() < length);
(swipe.now(length), settling)
};
if settling {
window.request_animation_frame();
}
let shut = {
let (swipe, id) = (swipe.clone(), self.id.clone());
move |cx: &mut App| {
swipe.update(cx, |swipe, cx| {
log::info!("swipeable row {id:?}: shut");
swipe.settle(Pixels::ZERO, length);
cx.notify();
})
}
};
let actions: Vec<_> = self
.actions
.into_iter()
.enumerate()
.map(|(ix, action)| {
let (fill, ink) = action.tone.colors(colors);
let (run, shut) = (action.run, shut.clone());
div()
.id((self.id.clone(), format!("action-{ix}")))
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_0p5()
.w(wide)
.h_full()
.bg(fill)
.text_color(ink)
.text_size(theme.text_size(TextSize::Xs))
.cursor_pointer()
.on_click(move |_, window, cx| {
shut(cx);
run(window, cx);
})
.child(Icon::new(action.icon).size(IconSize::Sm).color(ink))
.child(action.label)
})
.collect();
let (fingers, pressed) = (swipe.clone(), shut);
let id = self.id.clone();
div()
.id(self.id.clone())
.relative()
.overflow_hidden()
.on_scroll_wheel(move |event, _, cx| {
let ScrollDelta::Pixels(delta) = event.delta else {
return;
};
fingers.update(cx, |swipe, cx| {
match event.touch_phase {
TouchPhase::Started => {
swipe.offset = swipe.now(length);
swipe.since = None;
swipe.fingers = true;
swipe.across = None;
}
TouchPhase::Moved if swipe.fingers => {
if !*swipe.across.get_or_insert(delta.x.abs() > delta.y.abs()) {
return;
}
swipe.offset = (swipe.offset + delta.x).clamp(-reach, Pixels::ZERO);
cx.stop_propagation();
}
TouchPhase::Ended if swipe.fingers => {
swipe.fingers = false;
if swipe.across != Some(true) {
return;
}
let to = rest(swipe.offset, reach);
log::info!(
"swipeable row {id:?}: {}",
if to < Pixels::ZERO { "open" } else { "shut" }
);
swipe.settle(to, length);
}
_ => return,
}
cx.notify();
})
})
.child(
div()
.absolute()
.top_0()
.bottom_0()
.right_0()
.flex()
.children(actions),
)
.child(
div()
.id((self.id, "row"))
.relative()
.left(at)
.bg(colors.bg)
.block_mouse_except_scroll()
.when(at < Pixels::ZERO, |row| {
row.on_mouse_down(MouseButton::Left, move |_, _, cx| {
cx.stop_propagation();
pressed(cx);
})
})
.child(self.item),
)
}
}
#[cfg(test)]
mod tests {
use gpui::{Pixels, px};
use super::rest;
#[test]
fn a_released_row_rests_open_past_half_its_reach() {
assert_eq!(rest(px(-50.0), px(144.0)), Pixels::ZERO);
assert_eq!(rest(px(-80.0), px(144.0)), px(-144.0));
assert_eq!(rest(Pixels::ZERO, px(144.0)), Pixels::ZERO);
}
}
+274
View File
@@ -0,0 +1,274 @@
use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use gpui::{
Context, Entity, IntoElement, Modifiers, ParentElement, Pixels, Point, Render, ScrollDelta,
ScrollWheelEvent, SharedString, Styled, TestAppContext, TouchPhase, VisualTestContext, Window,
div, point, px,
};
use super::{
InfiniteList, ListItem, SelectableList, SortableList, SwipeAction, SwipeableListItem,
VirtualList,
};
use crate::{
primitives::{IconName, Measure},
theme::Theme,
};
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
}
/// Frames 2ms apart, past reduced motion's 1ms glides.
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
std::thread::sleep(std::time::Duration::from_millis(2));
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
const NAMES: [&str; 4] = ["a", "b", "c", "d"];
/// A multiple-choice list of four rows that keeps what it reports.
struct Picks(Vec<SharedString>);
impl Render for Picks {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
NAMES
.iter()
.fold(SelectableList::new("picks").multiple(), |list, name| {
list.row(*name, ListItem::new(*name, *name))
})
.selected(self.0.clone())
.on_change(move |keys, _, cx| view.update(cx, |picks, _| picks.0 = keys.to_vec()))
.w(px(240.0))
.h(px(200.0))
}
}
fn picks(view: &Entity<Picks>, cx: &mut VisualTestContext) -> Vec<String> {
view.read_with(cx, |picks, _| {
picks.0.iter().map(|key| key.to_string()).collect()
})
}
/// Row `ix`'s left padding: rows are 32 tall with 2 between.
fn row(ix: usize) -> Point<Pixels> {
point(px(6.0), px(16.0 + 34.0 * ix as f32))
}
#[gpui::test]
fn presses_pick_one_toggle_with_cmd_and_range_with_shift(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new()));
settle(cx);
cx.simulate_click(row(0), Modifiers::none());
settle(cx);
assert_eq!(picks(&view, cx), ["a"]);
cx.simulate_click(
row(2),
Modifiers {
shift: true,
..Modifiers::none()
},
);
settle(cx);
assert_eq!(picks(&view, cx), ["a", "b", "c"]);
cx.simulate_click(
row(1),
Modifiers {
platform: true,
..Modifiers::none()
},
);
settle(cx);
assert_eq!(picks(&view, cx), ["a", "c"]);
cx.simulate_keystrokes("down shift-down");
settle(cx);
assert_eq!(
picks(&view, cx),
["c", "d"],
"down picks one, shift-down extends from it"
);
cx.simulate_keystrokes("cmd-a");
settle(cx);
assert_eq!(picks(&view, cx), NAMES);
}
/// A row with two actions, 144 across; it reports where the row sits and which action ran.
struct Swiped {
left: Rc<Cell<Pixels>>,
ran: Rc<RefCell<Vec<&'static str>>>,
}
impl Render for Swiped {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let (left, archive, delete) = (self.left.clone(), self.ran.clone(), self.ran.clone());
div().w(px(320.0)).child(
SwipeableListItem::new(
"swiped",
Measure::new("row", move |bounds, _, _| left.set(bounds.origin.x))
.h(px(40.0))
.child("Invoice 2419"),
)
.action(SwipeAction::new(
"Archive",
IconName::Archive,
move |_, _| archive.borrow_mut().push("archive"),
))
.action(SwipeAction::new("Delete", IconName::Trash2, move |_, _| {
delete.borrow_mut().push("delete")
})),
)
}
}
fn scroll(cx: &mut VisualTestContext, phase: TouchPhase, x: f32, y: f32) {
cx.simulate_event(ScrollWheelEvent {
position: point(px(160.0), px(20.0)),
delta: ScrollDelta::Pixels(point(px(x), px(y))),
modifiers: Modifiers::none(),
touch_phase: phase,
});
settle(cx);
}
#[gpui::test]
fn a_sideways_swipe_opens_the_row_and_an_action_shuts_it(cx: &mut TestAppContext) {
setup(cx);
let (left, ran) = (
Rc::new(Cell::new(Pixels::ZERO)),
Rc::new(RefCell::new(Vec::new())),
);
let (seen, done) = (left.clone(), ran.clone());
let (_, cx) = cx.add_window_view(|_, _| Swiped {
left: seen,
ran: done,
});
settle(cx);
scroll(cx, TouchPhase::Started, 0.0, 0.0);
scroll(cx, TouchPhase::Moved, -2.0, -30.0);
scroll(cx, TouchPhase::Moved, -100.0, 0.0);
scroll(cx, TouchPhase::Ended, 0.0, 0.0);
assert_eq!(
left.get(),
Pixels::ZERO,
"a gesture that starts upright is the list's"
);
scroll(cx, TouchPhase::Started, 0.0, 0.0);
scroll(cx, TouchPhase::Moved, -50.0, 4.0);
assert_eq!(left.get(), px(-50.0), "the row follows the fingers");
scroll(cx, TouchPhase::Moved, -40.0, 0.0);
scroll(cx, TouchPhase::Ended, 0.0, 0.0);
assert_eq!(left.get(), px(-144.0), "past half its reach, it snaps open");
cx.simulate_click(point(px(300.0), px(20.0)), Modifiers::none());
settle(cx);
assert_eq!(*ran.borrow(), ["delete"]);
assert_eq!(left.get(), Pixels::ZERO, "an action shuts the row");
}
/// Rows in a short scrolling box that count how often they asked for more.
struct Paging {
rows: usize,
asked: usize,
}
impl Render for Paging {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
div().h(px(400.0)).child(
InfiniteList::new("paging")
.children((0..self.rows).map(|ix| div().h(px(20.0)).child(format!("Row {ix}"))))
.on_more(move |_, cx| view.update(cx, |paging, _| paging.asked += 1)),
)
}
}
#[gpui::test]
fn the_end_in_view_asks_for_more_once_a_page(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Paging { rows: 3, asked: 0 });
settle(cx);
assert_eq!(view.read_with(cx, |paging, _| paging.asked), 1);
settle(cx);
assert_eq!(
view.read_with(cx, |paging, _| paging.asked),
1,
"staying in view asks no more"
);
view.update(cx, |paging, cx| {
paging.rows = 6;
cx.notify();
});
settle(cx);
assert_eq!(
view.read_with(cx, |paging, _| paging.asked),
2,
"a new page asks again"
);
}
/// Three sortable rows; the moves the owner hears.
struct Sorting(Rc<RefCell<Vec<(usize, usize)>>>);
impl Render for Sorting {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let moves = self.0.clone();
NAMES[..3]
.iter()
.fold(SortableList::new("sorting"), |list, name| {
list.row(*name, ListItem::new(*name, *name))
})
.on_reorder(move |from, to, _, _| moves.borrow_mut().push((from, to)))
}
}
#[gpui::test]
fn alt_with_arrows_moves_the_current_row(cx: &mut TestAppContext) {
setup(cx);
let moves = Rc::new(RefCell::new(Vec::new()));
let heard = moves.clone();
let (_, cx) = cx.add_window_view(|_, _| Sorting(heard));
settle(cx);
cx.simulate_click(row(0), Modifiers::none());
settle(cx);
cx.simulate_keystrokes("alt-down down alt-up alt-up");
settle(cx);
assert_eq!(*moves.borrow(), [(0, 1), (2, 1), (1, 0)]);
}
/// A thousand rows, 20 tall, in a box 200 tall; it keeps the rows it was asked to build.
struct Long(Rc<RefCell<Vec<usize>>>);
impl Render for Long {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let built = self.0.clone();
VirtualList::new("long", 1000, move |ix, _, _| {
built.borrow_mut().push(ix);
div()
.h(px(20.0))
.child(format!("Row {ix}"))
.into_any_element()
})
.h(px(200.0))
}
}
#[gpui::test]
fn a_long_list_builds_only_what_is_near_the_view(cx: &mut TestAppContext) {
setup(cx);
let built = Rc::new(RefCell::new(Vec::new()));
let seen = built.clone();
let (_, cx) = cx.add_window_view(|_, _| Long(seen));
settle(cx);
let most = built.borrow().iter().copied().max().expect("some rows");
assert!((9..60).contains(&most), "built up to row {most} of 1000");
}
+10
View File
@@ -284,6 +284,16 @@ impl Theme {
px_to_rems(128.0)
}
/// How far past the viewport a long list builds rows ahead.
pub fn list_overdraw(&self) -> Rems {
px_to_rems(240.0)
}
/// One action behind a swiped row.
pub fn swipe_action(&self) -> Rems {
px_to_rems(72.0)
}
/// A QR code's tile.
pub fn qr_code(&self) -> Rems {
px_to_rems(160.0)
+1 -1
View File
@@ -9,7 +9,7 @@ use crate::{
theme::{ActiveTheme, TextSize},
};
const LEADING: f32 = 1.4;
pub(crate) const LEADING: f32 = 1.4;
/// Keeps `kept` chars: half from the front, half from the back.
fn elide(chars: &[char], kept: usize) -> String {
+1
View File
@@ -15,6 +15,7 @@ mod text;
pub use copy::CopyableText;
pub use effects::{GradientText, ShimmerText, Typewriter};
pub use emoji::Emoji;
pub(crate) use fit::LEADING;
pub use fit::{EllipsisTooltip, MiddleEllipsis};
pub use format::DurationStyle;
pub use formatted::{
+17 -11
View File
@@ -89,23 +89,29 @@
- [x] Rating display → `data_display::Stars`, read only with parts of a star; `forms::Rating` sets a score
- [x] Comparison → `data_display::Comparison`
## T13 Lists & Trees — `src/lists`
## T13a Lists & Trees · Lists — `src/lists`
- [x] List / ListItem → `lists::List`, `ListItem`
- [x] VirtualList → `lists::VirtualList`, on gpui's `list()`; rows as tall as they need
- [x] InfiniteList → `lists::InfiniteList`
- [x] SortableList → `lists::SortableList`; drags through `motion::Reorder`, Alt with arrows moves
- [x] SelectableList — single, multi, shift range → `lists::SelectableList`
- [x] GroupedList → `lists::GroupedList`, headers from `layout::StickyHeader`
- [x] SectionList → `lists::GroupedList`
- [x] ReorderableList → `lists::SortableList`
- [x] SwipeableListItem → `lists::SwipeableListItem`, `SwipeAction`; proved with real trackpad phases (`Step::Swipe`)
## T13b Lists & Trees · Trees
- [ ] List / ListItem
- [ ] VirtualList — native `list()` / `uniform_list()`, themed rows
- [ ] InfiniteList
- [ ] SortableList
- [ ] SelectableList — single, multi, shift range
- [ ] GroupedList — sticky group headers
- [ ] SectionList → `lists::GroupedList`
- [ ] ReorderableList → `lists::SortableList`
- [ ] SwipeableListItem — prove: horizontal trackpad scroll
- [ ] Tree / TreeView — also T06b TreeSelect
- [ ] VirtualTree → `lists::Tree`
- [ ] FileTree
- [ ] CheckboxTree
- [ ] Outline
- [ ] NestedList → `lists::Outline`
## T13c Lists & Trees · Files
- [ ] FileTree
- [ ] DirectoryListing
- [ ] Menu List → `menus::Menu`
- [ ] Transfer List → `forms::TransferList`