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
@@ -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()`.
|
||||
|
||||
@@ -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 |
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user