diff --git a/AGENTS.md b/AGENTS.md index cadda96..c918e75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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()`. diff --git a/TASKS.md b/TASKS.md index b121312..a024ce6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index 3f23e7a..aff0d5b 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -3,6 +3,7 @@ mod pages; mod probe; mod script; mod shell; +mod swipe; mod ui; use std::path::PathBuf; diff --git a/examples/gallery/pages/lists/long.rs b/examples/gallery/pages/lists/long.rs new file mode 100644 index 0000000..486f0dc --- /dev/null +++ b/examples/gallery/pages/lists/long.rs @@ -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 = 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())) +} diff --git a/examples/gallery/pages/lists/mod.rs b/examples/gallery/pages/lists/mod.rs new file mode 100644 index 0000000..75f53d7 --- /dev/null +++ b/examples/gallery/pages/lists/mod.rs @@ -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() +} diff --git a/examples/gallery/pages/lists/rows.rs b/examples/gallery/pages/lists/rows.rs new file mode 100644 index 0000000..47fbdd9 --- /dev/null +++ b/examples/gallery/pages/lists/rows.rs @@ -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."))) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 276c82e..ce4a08e 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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, ]; diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs index b43dfc2..2d38cd2 100644 --- a/examples/gallery/script.rs +++ b/examples/gallery/script.rs @@ -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 { diff --git a/examples/gallery/swipe.rs b/examples/gallery/swipe.rs new file mode 100644 index 0000000..df85f2e --- /dev/null +++ b/examples/gallery/swipe.rs @@ -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, + at: Point, + 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(()) +} diff --git a/src/data_display/badge.rs b/src/data_display/badge.rs index 74d9c81..c1dcd22 100644 --- a/src/data_display/badge.rs +++ b/src/data_display/badge.rs @@ -37,7 +37,7 @@ impl From 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), diff --git a/src/lib.rs b/src/lib.rs index 004b1a3..f850039 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/lists/grouped.rs b/src/lists/grouped.rs new file mode 100644 index 0000000..d0a938f --- /dev/null +++ b/src/lists/grouped.rs @@ -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)>, +} + +impl GroupedList { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + base: div(), + groups: Vec::new(), + } + } + + pub fn group( + mut self, + title: impl Into, + rows: impl IntoIterator, + ) -> 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) + }), + ) + } +} diff --git a/src/lists/item.rs b/src/lists/item.rs new file mode 100644 index 0000000..e169f9b --- /dev/null +++ b/src/lists/item.rs @@ -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; + +/// 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, + leading: Option, + trailing: Option, + selected: bool, + current: bool, + disabled: bool, + on_click: Option, +} + +impl ListItem { + pub fn new(id: impl Into, title: impl Into) -> 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) -> 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) { + 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) + })) + } +} diff --git a/src/lists/long.rs b/src/lists/long.rs new file mode 100644 index 0000000..3f57e20 --- /dev/null +++ b/src/lists/long.rs @@ -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 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, + 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; + +/// 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, +} + +impl InfiniteList { + pub fn new(id: impl Into) -> 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) { + 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) + } +} diff --git a/src/lists/mod.rs b/src/lists/mod.rs new file mode 100644 index 0000000..97dc023 --- /dev/null +++ b/src/lists/mod.rs @@ -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}; diff --git a/src/lists/select.rs b/src/lists/select.rs new file mode 100644 index 0000000..6893ef3 --- /dev/null +++ b/src/lists/select.rs @@ -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 { + 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; +type Picker = Rc; + +/// 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, + multiple: bool, + on_change: Option, +} + +impl SelectableList { + pub fn new(id: impl Into) -> 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, item: ListItem) -> Self { + self.rows.push((key.into(), item)); + self + } + + pub fn selected(mut self, keys: impl IntoIterator>) -> 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 { + 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); + } +} diff --git a/src/lists/sortable.rs b/src/lists/sortable.rs new file mode 100644 index 0000000..c475f61 --- /dev/null +++ b/src/lists/sortable.rs @@ -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; + +/// Where the keyboard moves row `at` of `count`, or None past either end. +fn nudged(at: usize, count: usize, up: bool) -> Option { + 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, +} + +impl SortableList { + pub fn new(id: impl Into) -> 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, 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); + } +} diff --git a/src/lists/swipe.rs b/src/lists/swipe.rs new file mode 100644 index 0000000..fb0b3e6 --- /dev/null +++ b/src/lists/swipe.rs @@ -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, + 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) -> 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, + from: Pixels, + since: Option, +} + +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, +} + +impl SwipeableListItem { + pub fn new(id: impl Into, 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); + } +} diff --git a/src/lists/tests.rs b/src/lists/tests.rs new file mode 100644 index 0000000..f29c3e7 --- /dev/null +++ b/src/lists/tests.rs @@ -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); + +impl Render for Picks { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, cx: &mut VisualTestContext) -> Vec { + 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 { + 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>, + ran: Rc>>, +} + +impl Render for Swiped { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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) -> 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>>); + +impl Render for Sorting { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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>>); + +impl Render for Long { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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"); +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index bbdc6a5..355c5bc 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -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) diff --git a/src/typography/fit.rs b/src/typography/fit.rs index b9ec913..cd548e4 100644 --- a/src/typography/fit.rs +++ b/src/typography/fit.rs @@ -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 { diff --git a/src/typography/mod.rs b/src/typography/mod.rs index 707592b..89cb83f 100644 --- a/src/typography/mod.rs +++ b/src/typography/mod.rs @@ -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::{ diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index e655fc0..fa45967 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -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`