T13a Lists: ListItem, List, SelectableList, VirtualList, InfiniteList, GroupedList, SortableList, SwipeableListItem (review pending: codex 401)
This commit is contained in:
23 files changed
+1820
-14
No files matched your search
@@ -3,6 +3,7 @@ mod pages;
|
||||
mod probe;
|
||||
mod script;
|
||||
mod shell;
|
||||
mod swipe;
|
||||
mod ui;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.")))
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user