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`.
|
- `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.
|
- 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.
|
- 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.
|
- 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.
|
- 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()`.
|
- 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
|
- [ ] T12b Data Display · Records
|
||||||
- [ ] T12c Data Display · Media
|
- [ ] T12c Data Display · Media
|
||||||
- [ ] T12d Data Display · Codes & measures
|
- [ ] T12d Data Display · Codes & measures
|
||||||
- [ ] T13 Lists & Trees
|
- [ ] T13a Lists & Trees · Lists
|
||||||
|
- [ ] T13b Lists & Trees · Trees
|
||||||
|
- [ ] T13c Lists & Trees · Files
|
||||||
- [ ] T14 Tables
|
- [ ] T14 Tables
|
||||||
- [ ] T15 Charts
|
- [ ] T15 Charts
|
||||||
- [ ] T16a Finance · Market charts
|
- [ ] T16a Finance · Market charts
|
||||||
@@ -186,3 +188,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T12b | 1 | — | pending: codex still logged out |
|
| T12b | 1 | — | pending: codex still logged out |
|
||||||
| T12c | 1 | — | pending: codex still logged out |
|
| T12c | 1 | — | pending: codex still logged out |
|
||||||
| T12d | 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 probe;
|
||||||
mod script;
|
mod script;
|
||||||
mod shell;
|
mod shell;
|
||||||
|
mod swipe;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
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 feedback;
|
||||||
mod forms;
|
mod forms;
|
||||||
mod layout;
|
mod layout;
|
||||||
|
mod lists;
|
||||||
mod menus;
|
mod menus;
|
||||||
mod motion;
|
mod motion;
|
||||||
mod navigation;
|
mod navigation;
|
||||||
@@ -39,6 +40,7 @@ pub const ALL: &[Page] = &[
|
|||||||
feedback::PAGE,
|
feedback::PAGE,
|
||||||
motion::PAGE,
|
motion::PAGE,
|
||||||
data::PAGE,
|
data::PAGE,
|
||||||
|
lists::PAGE,
|
||||||
theme::PAGE,
|
theme::PAGE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::{
|
|||||||
capture::{number, popup_number, snapshot},
|
capture::{number, popup_number, snapshot},
|
||||||
probe::{Opened, Probes},
|
probe::{Opened, Probes},
|
||||||
shell::Gallery,
|
shell::Gallery,
|
||||||
|
swipe::{BEGAN, CHANGED, ENDED, swipe},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One scripted input, aimed at a probe by key.
|
/// One scripted input, aimed at a probe by key.
|
||||||
@@ -24,6 +25,8 @@ pub enum Step {
|
|||||||
RightAt(&'static str, f32, f32),
|
RightAt(&'static str, f32, f32),
|
||||||
/// Drags from the last point to an offset, in small steps.
|
/// Drags from the last point to an offset, in small steps.
|
||||||
DragTo(&'static str, f32, f32),
|
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),
|
UpAt(&'static str, f32, f32),
|
||||||
Key(&'static str),
|
Key(&'static str),
|
||||||
/// Types text into whatever holds focus, one key at a time.
|
/// 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::RightDown, at, cx)?;
|
||||||
send(window, Mouse::RightUp, 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) => {
|
Step::DragTo(key, x, y) => {
|
||||||
let goal = target_bounds(window, key, cx).await?.origin + point(px(x), px(y));
|
let goal = target_bounds(window, key, cx).await?.origin + point(px(x), px(y));
|
||||||
for step in 1..=DRAG_STEPS {
|
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 {
|
impl Tone {
|
||||||
/// The fill, then the text on it.
|
/// 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));
|
let severity = |severity: Severity| (severity.subtle(colors), severity.color(colors));
|
||||||
match self {
|
match self {
|
||||||
Self::Neutral => (colors.hover, colors.fg_muted),
|
Self::Neutral => (colors.hover, colors.fg_muted),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod data_display;
|
|||||||
pub mod feedback;
|
pub mod feedback;
|
||||||
pub mod forms;
|
pub mod forms;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod lists;
|
||||||
pub mod menus;
|
pub mod menus;
|
||||||
pub mod motion;
|
pub mod motion;
|
||||||
pub mod navigation;
|
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)
|
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.
|
/// A QR code's tile.
|
||||||
pub fn qr_code(&self) -> Rems {
|
pub fn qr_code(&self) -> Rems {
|
||||||
px_to_rems(160.0)
|
px_to_rems(160.0)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
theme::{ActiveTheme, TextSize},
|
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.
|
/// Keeps `kept` chars: half from the front, half from the back.
|
||||||
fn elide(chars: &[char], kept: usize) -> String {
|
fn elide(chars: &[char], kept: usize) -> String {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod text;
|
|||||||
pub use copy::CopyableText;
|
pub use copy::CopyableText;
|
||||||
pub use effects::{GradientText, ShimmerText, Typewriter};
|
pub use effects::{GradientText, ShimmerText, Typewriter};
|
||||||
pub use emoji::Emoji;
|
pub use emoji::Emoji;
|
||||||
|
pub(crate) use fit::LEADING;
|
||||||
pub use fit::{EllipsisTooltip, MiddleEllipsis};
|
pub use fit::{EllipsisTooltip, MiddleEllipsis};
|
||||||
pub use format::DurationStyle;
|
pub use format::DurationStyle;
|
||||||
pub use formatted::{
|
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] Rating display → `data_display::Stars`, read only with parts of a star; `forms::Rating` sets a score
|
||||||
- [x] Comparison → `data_display::Comparison`
|
- [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
|
- [ ] Tree / TreeView — also T06b TreeSelect
|
||||||
- [ ] VirtualTree → `lists::Tree`
|
- [ ] VirtualTree → `lists::Tree`
|
||||||
- [ ] FileTree
|
|
||||||
- [ ] CheckboxTree
|
- [ ] CheckboxTree
|
||||||
- [ ] Outline
|
- [ ] Outline
|
||||||
- [ ] NestedList → `lists::Outline`
|
- [ ] NestedList → `lists::Outline`
|
||||||
|
|
||||||
|
## T13c Lists & Trees · Files
|
||||||
|
|
||||||
|
- [ ] FileTree
|
||||||
- [ ] DirectoryListing
|
- [ ] DirectoryListing
|
||||||
- [ ] Menu List → `menus::Menu`
|
- [ ] Menu List → `menus::Menu`
|
||||||
- [ ] Transfer List → `forms::TransferList`
|
- [ ] Transfer List → `forms::TransferList`
|
||||||
|
|||||||
Reference in new issue
Block a user