T11c motion: transitions, stagger, presence that folds, flip that glides, drag to reorder; motion keeps each row's identity

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 18:01:29 -04:00
1 parent ce31c7fb06
commit 8e7e7ccace
10 files changed
+1156 -10

No files matched your search

+1
View File
@@ -67,6 +67,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again.
- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar.
- `primitives::Severity` names info, success, warning and danger with their colors and icons, for alerts and feedback alike.
- Rows that move are keyed. `motion::Flip` places them absolutely from last frame's heights, so no frame shows a row at its new place before it glides there, and drives the glide from elapsed time: an animation id that changes per move would rebuild the row's keyed state. `Reorder` holds a row by key, drags with an empty ghost and moves the held row itself.
- A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`.
- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`).
- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint. Key only the part that moves: a keyed ancestor gives every descendant a new id, and focused buttons lose focus.
+4 -1
View File
@@ -40,7 +40,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T10b Feedback · States
- [x] T11a Loading · Spinners & skeletons — `tasks/ch11-20.md`
- [x] T11b Loading · Progress & states
- [ ] T11c Motion · Presence & order
- [x] T11c Motion · Presence & order
- [ ] T11d Motion · Effects
- [ ] T12 Data Display
- [ ] T13 Lists & Trees
@@ -173,3 +173,6 @@ Per-component lines live in `tasks/`. Tags there:
| T11a | 3 | PASS | |
| T11b | 1 | FAIL | the sweeping bar grew in a tall flex column, the overlay's veil ignored rounded corners, the fail demo could not start a load, and two UploadList docs said more than the rows show |
| T11b | 2 | PASS | |
| T11c | 1 | FAIL | a drag kept a stale row index and panicked when the list shrank, a move changed each row's animation id and rebuilt its state, and repeated demo tags shared a key |
| T11c | 2 | FAIL | a row first seen hidden drew open and asked for no frame, so it lingered until something else redrew |
| T11c | 3 | PASS | |
+16
View File
@@ -1,4 +1,5 @@
mod loading;
mod moves;
mod progress;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
@@ -42,6 +43,16 @@ const SCRIPT: &[Step] = &[
Step::Click("uploads-tick"),
Step::Wait(400),
Step::Shot("uploads"),
Step::Click("transition-toggle"),
Step::Wait(80),
Step::Shot("transition-leaving"),
Step::Click("transition-toggle"),
Step::Click("presence-add"),
Step::Wait(80),
Step::Shot("presence-arriving"),
Step::Click("flip-shuffle"),
Step::Wait(100),
Step::Shot("flip-gliding"),
Step::Rest,
];
@@ -58,5 +69,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(progress::suspense(window, cx))
.child(progress::refresh(window, cx))
.child(progress::uploads(window, cx))
.child(moves::transitions(window, cx))
.child(moves::stagger(window, cx))
.child(moves::presence(window, cx))
.child(moves::flip(window, cx))
.child(moves::reorder(window, cx))
.into_any_element()
}
+251
View File
@@ -0,0 +1,251 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton},
motion::{AnimatePresence, Entrance, Flip, Reorder, Stagger, Transition},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
typography::Caption,
};
use gpui::{App, Div, IntoElement, ParentElement, SharedString, Styled, Window, div};
use crate::{
probe::probe,
ui::{keep, row, section, set, specimen, specimens},
};
fn card(text: impl Into<SharedString>, cx: &App) -> Div {
let theme = cx.theme();
div()
.px_4()
.py_3()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.surface)
.child(text.into())
}
/// A row in a list demo: a hairline under it.
fn line(text: impl Into<SharedString>, cx: &App) -> Div {
let theme = cx.theme();
div()
.flex()
.items_center()
.gap_3()
.px_4()
.py_2()
.border_b_1()
.border_color(theme.colors.border)
.bg(theme.colors.surface)
.child(text.into())
}
pub fn transitions(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let shown = keep("transition-shown", || true, window, cx);
let (now, toggle) = (*shown.read(cx), shown.clone());
let entrances = [
(Entrance::Fade, "fade"),
(Entrance::Rise, "rise"),
(Entrance::Slide, "slide"),
];
section(
"Transition",
"Content that fades, rises or slides in, and leaves the way it came.",
cx,
)
.child(specimens().children(entrances.map(|(entrance, name)| {
specimen(
name,
div().w_48().h_12().child(
Transition::new(SharedString::from(format!("transition-{name}")), now)
.entrance(entrance)
.child(card("Hello", cx)),
),
cx,
)
})))
.child(
row().child(probe(
"transition-toggle",
Button::new("transition-toggle", if now { "Hide" } else { "Show" })
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| set(&toggle, !now, cx)),
)),
)
}
pub fn stagger(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let turn = keep("stagger-turn", || 0usize, window, cx);
let (now, replay) = (*turn.read(cx), turn.clone());
section(
"Stagger",
"Children arrive one after another, a few frames apart.",
cx,
)
.child(
div().w_96().child(
Stagger::new(SharedString::from(format!("stagger-{now}"))).children(
["Inbox", "Drafts", "Sent", "Archive", "Trash"].map(|name| line(name, cx)),
),
),
)
.child(
row().child(probe(
"stagger-replay",
Button::new("stagger-replay", "Replay")
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| set(&replay, now + 1, cx)),
)),
)
}
/// A tag in the presence demo: a stable number, its name, and whether it shows.
type Tag = (usize, &'static str, bool);
pub fn presence(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
const MORE: [&str; 4] = ["Typography", "Color", "Layout", "Sound"];
let tags = keep(
"presence-tags",
|| vec![(0, "Design", true), (1, "Motion", true), (2, "Rust", true)],
window,
cx,
);
let list: Vec<Tag> = tags.read(cx).clone();
let (add, gone) = (tags.clone(), tags.clone());
let rows = list.iter().fold(
AnimatePresence::new("presence").on_gone(move |key, _, cx| {
let left: Vec<Tag> = gone
.read(cx)
.iter()
.filter(|(number, _, _)| format!("tag-{number}") != key.as_ref())
.copied()
.collect();
set(&gone, left, cx)
}),
|rows, (number, name, shown)| {
let (hide, number) = (tags.clone(), *number);
rows.row(
SharedString::from(format!("tag-{number}")),
*shown,
line(*name, cx).justify_between().child(
IconButton::new(
SharedString::from(format!("presence-remove-{number}")),
IconName::X,
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
let then: Vec<Tag> = hide
.read(cx)
.iter()
.map(|tag| {
if tag.0 == number {
(tag.0, tag.1, false)
} else {
*tag
}
})
.collect();
set(&hide, then, cx)
}),
),
)
},
);
section(
"AnimatePresence",
"Rows fold open as they arrive and fold away as they go; the rest glide to close the gap.",
cx,
)
.child(div().w_96().child(rows))
.child(
row().child(probe(
"presence-add",
Button::new("presence-add", "Add a tag")
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| {
let mut then = add.read(cx).clone();
let number = then.iter().map(|tag| tag.0 + 1).max().unwrap_or(0);
then.push((number, MORE[number % MORE.len()], true));
set(&add, then, cx);
}),
)),
)
}
pub fn flip(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let order = keep(
"flip-order",
|| vec!["Aurora", "Basalt", "Cinder", "Dune", "Ember"],
window,
cx,
);
let (now, shuffle) = (order.read(cx).clone(), order.clone());
let list = now.iter().fold(Flip::new("flip"), |list, name| {
list.row(*name, line(*name, cx))
});
section(
"Flip",
"When the order changes, each row glides from where it was to where it goes.",
cx,
)
.child(div().w_96().child(list))
.child(
row().child(probe(
"flip-shuffle",
Button::new("flip-shuffle", "Shuffle")
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| {
let mut then = shuffle.read(cx).clone();
then.rotate_left(2);
then.swap(0, 3);
set(&shuffle, then, cx)
}),
)),
)
}
pub fn reorder(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let tasks = keep(
"reorder-tasks",
|| {
vec![
"Write the brief",
"Sketch the flows",
"Pick the type",
"Build the prototype",
"Test with five people",
]
},
window,
cx,
);
let (now, moved) = (tasks.read(cx).clone(), tasks.clone());
let muted = cx.theme().colors.fg_subtle;
let list = now.iter().fold(
Reorder::new("reorder").on_reorder(move |from, to, _, cx| {
let mut then = moved.read(cx).clone();
let task = then.remove(from);
then.insert(to, task);
set(&moved, then, cx)
}),
|list, task| {
list.row(
*task,
line(*task, cx).child(div().flex_1()).child(
Icon::new(IconName::GripVertical)
.size(IconSize::Sm)
.color(muted),
),
)
},
);
section(
"Reorder",
"Drag a row: it follows the pointer and the others glide aside to make room.",
cx,
)
.child(div().w_96().child(list))
.child(Caption::new(
"Motion is gpui's with_animation, timed by motion::duration and eased by the curves in motion. Spring is motion::spring. CountUp is typography::AnimatedNumber::count_up. A collapse is layout::Collapsible. A scale entrance is blocked: gpui 0.2.2 transforms only icons.",
))
}
+282
View File
@@ -0,0 +1,282 @@
use std::{collections::HashMap, rc::Rc, time::Instant};
use gpui::{
AnyElement, App, ElementId, Entity, IntoElement, ParentElement, Pixels, RenderOnce,
SharedString, Styled, Window, canvas, div,
};
use super::{BASE, duration, ease_in_out_cubic, ease_out_cubic};
use crate::theme::{ActiveTheme, Elevation};
/// Where a row sits, where it glides from, and since when.
#[derive(Clone, Copy)]
struct Place {
height: Pixels,
top: Pixels,
from: Pixels,
since: Instant,
turn: usize,
}
impl Place {
fn now(&self, length: std::time::Duration) -> Pixels {
let share =
(self.since.elapsed().as_secs_f32() / length.as_secs_f32().max(f32::EPSILON)).min(1.0);
self.from + (self.top - self.from) * ease_in_out_cubic(share)
}
}
/// Measures a row's height as it draws; a change asks for the next frame.
fn measure<T: 'static>(
state: &Entity<HashMap<SharedString, T>>,
key: SharedString,
height: impl Fn(&mut T) -> &mut Pixels + 'static,
) -> impl IntoElement {
let state = state.clone();
canvas(
move |bounds, window, cx| {
let changed = state.update(cx, |rows, _| {
let Some(row) = rows.get_mut(&key) else {
return false;
};
let slot = height(row);
let changed = *slot != bounds.size.height;
*slot = bounds.size.height;
changed
});
if changed {
state.update(cx, |_, cx| cx.notify());
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
/// A column whose rows glide to their new places when the order changes: first, last, invert, play. Rows are keyed so each is followed.
#[derive(IntoElement)]
pub struct Flip {
id: ElementId,
rows: Vec<(SharedString, AnyElement)>,
lifted: Option<(SharedString, Pixels)>,
}
impl Flip {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
rows: Vec::new(),
lifted: None,
}
}
/// A row in display order; its key stays with it as the order changes.
pub fn row(mut self, key: impl Into<SharedString>, row: impl IntoElement) -> Self {
self.rows.push((key.into(), row.into_any_element()));
self
}
/// Draws one row raised at `top`, free of its slot, as a drag holds it.
pub(super) fn lifted(mut self, key: SharedString, top: Pixels) -> Self {
self.lifted = Some((key, top));
self
}
}
impl RenderOnce for Flip {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state((self.id.clone(), "places"), cx, |_, _| {
HashMap::<SharedString, Place>::new()
});
let length = duration(BASE, cx);
let keys: Vec<SharedString> = self.rows.iter().map(|(key, _)| key.clone()).collect();
let places = state.update(cx, |places, _| {
places.retain(|key, _| keys.contains(key));
let measured = keys.iter().all(|key| {
places
.get(key)
.is_some_and(|place| place.height > Pixels::ZERO)
});
let mut top = Pixels::ZERO;
for key in &keys {
let place = places.entry(key.clone()).or_insert(Place {
height: Pixels::ZERO,
top,
from: top,
since: Instant::now(),
turn: 0,
});
if place.top != top {
place.from = if measured { place.now(length) } else { top };
place.top = top;
place.since = Instant::now();
place.turn += 1;
}
top += place.height;
}
(places.clone(), top)
});
let (places, total) = places;
if places
.values()
.any(|place| place.turn > 0 && place.since.elapsed() < length)
{
window.request_animation_frame();
}
let shadow = cx.theme().elevation(Elevation::Floating);
let rows = self.rows.into_iter().map(|(key, row)| {
let place = places[&key];
let slot = div()
.absolute()
.left_0()
.right_0()
.child(row)
.child(measure(&state, key.clone(), |place: &mut Place| {
&mut place.height
}));
match &self.lifted {
Some((lifted, top)) if *lifted == key => (
true,
slot.top(*top).shadow(shadow.clone()).into_any_element(),
),
_ => (false, slot.top(place.now(length)).into_any_element()),
}
});
let (mut rest, mut raised): (Vec<_>, Vec<_>) = (Vec::new(), Vec::new());
for (lifted, row) in rows {
if lifted {
raised.push(row)
} else {
rest.push(row)
}
}
rest.append(&mut raised);
div().relative().w_full().h(total).children(rest)
}
}
/// Whether a row shows, how tall it is, and when it last turned.
#[derive(Clone, Copy)]
struct Presence {
height: Pixels,
shown: bool,
since: Instant,
turn: usize,
told: bool,
}
type OnGone = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A hidden row is gone once its fold ends, or at once if it was never shown here.
fn gone(presence: &Presence, length: std::time::Duration) -> bool {
presence.turn == 0 || presence.since.elapsed() >= length
}
/// Rows that fold open and fade in when they arrive, and fold away when the owner marks them gone. `on_gone` says when a row may be dropped. Rows there at first stay still.
#[derive(IntoElement)]
pub struct AnimatePresence {
id: ElementId,
rows: Vec<(SharedString, bool, AnyElement)>,
on_gone: Option<OnGone>,
}
impl AnimatePresence {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
rows: Vec::new(),
on_gone: None,
}
}
/// A row, and whether it should show; keep passing it while it leaves.
pub fn row(mut self, key: impl Into<SharedString>, shown: bool, row: impl IntoElement) -> Self {
self.rows.push((key.into(), shown, row.into_any_element()));
self
}
pub fn on_gone(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_gone = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AnimatePresence {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let first = window.use_keyed_state((self.id.clone(), "first"), cx, |_, _| true);
let state = window.use_keyed_state((self.id.clone(), "rows"), cx, |_, _| {
HashMap::<SharedString, Presence>::new()
});
let still = *first.read(cx);
first.update(cx, |first, _| *first = false);
let length = duration(BASE, cx);
let on_gone = self.on_gone;
let mut rows = Vec::new();
for (key, shown, row) in self.rows {
let presence = state.update(cx, |rows, _| {
let presence = rows.entry(key.clone()).or_insert(Presence {
height: Pixels::ZERO,
shown,
since: Instant::now(),
turn: usize::from(!still && shown),
told: false,
});
if presence.shown != shown {
presence.shown = shown;
presence.since = Instant::now();
presence.turn += 1;
presence.told = false;
}
let copy = *presence;
if !shown && gone(presence, length) {
presence.told = true;
}
copy
});
let settled = presence.since.elapsed() >= length;
if !shown && gone(&presence, length) {
if let Some(on_gone) = on_gone.clone().filter(|_| !presence.told) {
let key = key.clone();
window.defer(cx, move |window, cx| on_gone(&key, window, cx));
}
continue;
}
if !settled && presence.turn > 0 {
window.request_animation_frame();
}
let frame = div().relative().overflow_hidden().child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.child(row)
.child(measure(&state, key.clone(), |row: &mut Presence| {
&mut row.height
})),
);
let share = (presence.since.elapsed().as_secs_f32()
/ length.as_secs_f32().max(f32::EPSILON))
.min(1.0);
let open = if presence.turn == 0 {
1.0
} else {
ease_out_cubic(if shown { share } else { 1.0 - share })
};
rows.push(
frame
.h(presence.height * open)
.opacity(open)
.into_any_element(),
);
}
div().flex().flex_col().children(rows)
}
}
+6
View File
@@ -1,18 +1,24 @@
mod changes;
mod curve;
mod list;
mod overlay;
mod progress;
mod reorder;
mod skeleton;
mod slide;
mod spinner;
mod transition;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub(crate) use changes::changes;
pub use curve::*;
pub use list::{AnimatePresence, Flip};
pub use overlay::{LazyLoad, LoadingOverlay, Refresh, RefreshIndicator, TypingIndicator};
pub use progress::{ProgressBar, ProgressRing};
pub use reorder::Reorder;
pub use skeleton::{Shimmer, Skeleton, SkeletonAvatar, SkeletonCard, SkeletonTable, SkeletonText};
pub(crate) use slide::{Axis, Marker, glide, measure_item, measure_origin, slide};
pub use spinner::{Spinner, SpinnerStyle};
pub use transition::{Entrance, Stagger, Transition};
+226
View File
@@ -0,0 +1,226 @@
use std::{collections::HashMap, rc::Rc};
use gpui::{
AnyElement, App, AppContext as _, ElementId, EmptyView, EntityId, InteractiveElement,
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement,
Styled, Window, canvas, div,
};
use super::Flip;
/// A row on its way, by key, and whose list it belongs to.
struct RowDrag {
owner: EntityId,
key: SharedString,
}
/// The row held, by key, where it would land, its top, and where the pointer took it.
#[derive(Clone)]
struct Hold {
key: SharedString,
target: usize,
top: Pixels,
grab: Pixels,
}
type OnReorder = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
/// Rows reordered by dragging: the held row follows the pointer, the others glide aside, and on drop `on_reorder(from, to)` asks the owner to move row `from` to place `to`.
#[derive(IntoElement)]
pub struct Reorder {
id: ElementId,
rows: Vec<(SharedString, AnyElement)>,
on_reorder: Option<OnReorder>,
}
impl Reorder {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
rows: Vec::new(),
on_reorder: None,
}
}
pub fn row(mut self, key: impl Into<SharedString>, row: impl IntoElement) -> Self {
self.rows.push((key.into(), row.into_any_element()));
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
}
}
/// Where the held row lands: past every other row whose middle is above its own.
fn landing(heights: &[Pixels], from: usize, top: Pixels) -> usize {
let middle = top + heights[from] / 2.0;
let mut y = Pixels::ZERO;
let mut target = 0;
let others = heights
.iter()
.enumerate()
.filter(|(ix, _)| *ix != from)
.map(|(_, height)| height);
for height in others {
if y + *height / 2.0 < middle {
target += 1;
}
y += *height;
}
target
}
impl RenderOnce for Reorder {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let hold = window.use_keyed_state((self.id.clone(), "hold"), cx, |_, _| None::<Hold>);
let sizes = window.use_keyed_state((self.id.clone(), "heights"), cx, |_, _| {
HashMap::<SharedString, Pixels>::new()
});
let owner = hold.entity_id();
if hold.read(cx).is_some() && !cx.has_active_drag() {
log::debug!("reorder {:?}: the drag ended elsewhere", self.id);
hold.update(cx, |hold, _| *hold = None);
}
let keys: Vec<SharedString> = self.rows.iter().map(|(key, _)| key.clone()).collect();
let held = hold.read(cx).clone().and_then(|held| {
let from = keys.iter().position(|key| *key == held.key);
if from.is_none() {
log::info!("reorder: the held row {} left the list", held.key);
}
from.map(|from| (from, held.target.min(keys.len() - 1), held.top))
});
if held.is_none() && hold.read(cx).is_some() {
hold.update(cx, |hold, _| *hold = None);
}
let heights: Vec<Pixels> = keys
.iter()
.map(|key| sizes.read(cx).get(key).copied().unwrap_or_default())
.collect();
let mut order: Vec<usize> = (0..keys.len()).collect();
if let Some((from, target, _)) = held {
let row = order.remove(from);
order.insert(target, row);
}
let mut rows: Vec<Option<AnyElement>> =
self.rows.into_iter().map(|(_, row)| Some(row)).collect();
let mut flip = Flip::new((self.id.clone(), "flip"));
for ix in order {
let key = keys[ix].clone();
let (grab, measured) = (hold.clone(), sizes.clone());
let row = div()
.id((self.id.clone(), key.clone()))
.relative()
.child(rows[ix].take().expect("each row once"))
.child(
canvas(
{
let key = key.clone();
move |bounds, window, cx| {
if measured.read(cx).get(&key) != Some(&bounds.size.height) {
measured.update(cx, |sizes, cx| {
sizes.insert(key.clone(), bounds.size.height);
cx.notify();
});
window.request_animation_frame();
}
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.on_drag(
RowDrag {
owner,
key: key.clone(),
},
move |drag, offset, _, cx| {
log::info!("reorder: row {} lifted", drag.key);
grab.update(cx, |hold, cx| {
*hold = Some(Hold {
key: drag.key.clone(),
target: ix,
top: Pixels::ZERO,
grab: offset.y,
});
cx.notify();
});
cx.new(|_| EmptyView)
},
);
flip = flip.row(key, row);
}
if let Some((from, _, top)) = held {
flip = flip.lifted(keys[from].clone(), top);
}
let total = heights
.iter()
.fold(Pixels::ZERO, |total, height| total + *height);
let (moves, drops, on_reorder) = (hold.clone(), hold, self.on_reorder);
let dropped = keys.clone();
div()
.id(self.id)
.child(flip)
.on_drag_move::<RowDrag>(move |event, _, cx| {
if event.drag(cx).owner != owner {
return;
}
let pointer = event.event.position.y - event.bounds.top();
moves.update(cx, |hold, cx| {
let Some(hold) = hold else { return };
let Some(from) = keys.iter().position(|key| *key == hold.key) else {
return;
};
let reach = total - heights[from];
hold.top = (pointer - hold.grab).clamp(Pixels::ZERO, reach.max(Pixels::ZERO));
hold.target = landing(&heights, from, hold.top);
cx.notify();
});
})
.on_drop(move |drag: &RowDrag, window, cx| {
if drag.owner != owner {
return;
}
let Some(held) = drops.update(cx, |hold, cx| {
cx.notify();
hold.take()
}) else {
return;
};
let Some(from) = dropped.iter().position(|key| *key == held.key) else {
return;
};
let target = held.target.min(dropped.len() - 1);
if from != target {
log::info!("reorder: row {from} to {target}");
if let Some(on_reorder) = &on_reorder {
on_reorder(from, target, window, cx);
}
}
})
}
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::landing;
#[test]
fn a_held_row_lands_past_the_middles_above_it() {
let heights = [px(20.0); 5];
assert_eq!(landing(&heights, 0, px(40.0)), 2);
assert_eq!(landing(&heights, 0, px(0.0)), 0);
assert_eq!(landing(&heights, 4, px(0.0)), 0);
assert_eq!(landing(&heights, 2, px(70.0)), 4);
}
}
+189
View File
@@ -150,3 +150,192 @@ fn a_sweeping_bar_keeps_its_thickness_in_a_tall_column(cx: &mut TestAppContext)
});
assert_eq!(after.get(), thickness, "the bar took only its thickness");
}
/// A transition, a presence list and a reorderable list, recording what they report.
struct Stage {
shown: bool,
rows: Vec<(&'static str, bool)>,
order: Vec<&'static str>,
/// The top of what sits below the transition.
drawn: Rc<Cell<usize>>,
/// Each reorder row's keyed state, by row.
seen: Rc<std::cell::RefCell<std::collections::HashMap<&'static str, gpui::EntityId>>>,
log: Vec<String>,
}
/// A 20px row that keeps keyed state and says which entity holds it.
#[derive(IntoElement)]
struct Marker {
key: &'static str,
seen: Rc<std::cell::RefCell<std::collections::HashMap<&'static str, gpui::EntityId>>>,
}
impl gpui::RenderOnce for Marker {
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let state = window.use_keyed_state(self.key, cx, |_, _| ());
self.seen.borrow_mut().insert(self.key, state.entity_id());
div().h(px(20.0)).child(self.key)
}
}
impl Render for Stage {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let drawn = self.drawn.clone();
let (gone, moved) = (cx.entity(), cx.entity());
let presence = self.rows.iter().fold(
super::AnimatePresence::new("presence").on_gone(move |key, _, cx| {
gone.update(cx, |stage, _| stage.log.push(format!("gone {key}")))
}),
|list, (key, shown)| list.row(*key, *shown, div().h(px(20.0)).child(*key)),
);
let reorder = self.order.iter().fold(
super::Reorder::new("reorder").on_reorder(move |from, to, _, cx| {
moved.update(cx, |stage, cx| {
let row = stage.order.remove(from);
stage.order.insert(to, row);
stage.log.push(format!("moved {from} to {to}"));
cx.notify();
})
}),
|list, key| {
list.row(
*key,
Marker {
key,
seen: self.seen.clone(),
},
)
},
);
div()
.w(px(300.0))
.child(reorder)
.child(super::Transition::new("fade", self.shown).child(div().h(px(10.0))))
.child(
crate::primitives::Measure::new("below", move |bounds, _, _| {
drawn.set(f32::from(bounds.origin.y) as usize)
})
.h(px(1.0)),
)
.child(presence)
}
}
fn stage(cx: &mut TestAppContext) -> (gpui::Entity<Stage>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
let (view, cx) = cx.add_window_view(|_, _| Stage {
shown: true,
rows: vec![("a", true), ("b", true)],
order: vec!["one", "two", "three", "four", "five"],
drawn: Rc::new(Cell::new(0)),
seen: Rc::default(),
log: Vec::new(),
});
settle(cx);
(view, cx)
}
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();
}
}
#[gpui::test]
fn a_hidden_transition_leaves_then_stops_drawing(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
let below = view.read_with(cx, |stage, _| stage.drawn.clone());
assert_eq!(below.get(), 110, "the reorder list, then the shown content");
view.update(cx, |stage, cx| {
stage.shown = false;
cx.notify();
});
settle(cx);
assert_eq!(below.get(), 100, "the content left the layout");
}
#[gpui::test]
fn a_row_marked_gone_is_reported_once(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
view.update(cx, |stage, cx| {
stage.rows[1].1 = false;
cx.notify();
});
settle(cx);
settle(cx);
let log = view.read_with(cx, |stage, _| stage.log.clone());
assert_eq!(log, ["gone b"]);
}
#[gpui::test]
fn dragging_a_row_down_moves_it_past_the_rows_it_passes(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
let at = |y: f32| point(px(100.0), px(y));
cx.simulate_mouse_move(at(10.0), None, Modifiers::none());
cx.simulate_mouse_down(at(10.0), gpui::MouseButton::Left, Modifiers::none());
for y in [16.0, 30.0, 45.0, 50.0] {
cx.simulate_mouse_move(at(y), Some(gpui::MouseButton::Left), Modifiers::none());
cx.run_until_parked();
}
cx.simulate_mouse_up(at(50.0), gpui::MouseButton::Left, Modifiers::none());
settle(cx);
let (order, log) = view.read_with(cx, |stage, _| (stage.order.clone(), stage.log.clone()));
assert_eq!(log, ["moved 0 to 2"]);
assert_eq!(order, ["two", "three", "one", "four", "five"]);
}
#[gpui::test]
fn a_moved_row_keeps_its_state(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
let seen = view.read_with(cx, |stage, _| stage.seen.clone());
let before = seen.borrow()["one"];
view.update(cx, |stage, cx| {
stage.order.reverse();
cx.notify();
});
settle(cx);
assert_eq!(
seen.borrow()["one"],
before,
"the same state after the move"
);
}
#[gpui::test]
fn a_list_that_shrinks_under_a_drag_lets_it_go(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
let at = |y: f32| point(px(100.0), px(y));
cx.simulate_mouse_move(at(90.0), None, Modifiers::none());
cx.simulate_mouse_down(at(90.0), gpui::MouseButton::Left, Modifiers::none());
for y in [84.0, 60.0] {
cx.simulate_mouse_move(at(y), Some(gpui::MouseButton::Left), Modifiers::none());
cx.run_until_parked();
}
view.update(cx, |stage, cx| {
stage.order.truncate(2);
cx.notify();
});
settle(cx);
cx.simulate_mouse_up(at(20.0), gpui::MouseButton::Left, Modifiers::none());
settle(cx);
let log = view.read_with(cx, |stage, _| stage.log.clone());
assert!(log.is_empty(), "no move for a row that left: {log:?}");
}
#[gpui::test]
fn a_row_hidden_from_the_start_is_gone_at_once(cx: &mut TestAppContext) {
let (view, cx) = stage(cx);
cx.update(|_, cx| Theme::update(cx, |theme| theme.reduced_motion = false));
view.update(cx, |stage, cx| {
stage.rows.push(("c", false));
cx.notify();
});
settle(cx);
let log = view.read_with(cx, |stage, _| stage.log.clone());
assert_eq!(log, ["gone c"], "no fold to wait for");
}
+169
View File
@@ -0,0 +1,169 @@
use std::time::{Duration, Instant};
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce,
Styled, Window, div,
};
use smallvec::SmallVec;
use super::{BASE, NUDGE, duration, ease_out_cubic};
use crate::theme::ActiveTheme;
/// How content comes and goes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Entrance {
#[default]
Fade,
/// Fades while rising from a little below.
Rise,
/// Fades while sliding in from the left, and out the same way.
Slide,
}
/// Where a transition stands: shown or not, since when, and how many times it turned.
struct Turns {
shown: bool,
since: Instant,
turn: usize,
}
/// Shows or hides its content with a fade, a rise or a slide. It keeps drawing the content while it leaves, so pass the content either way.
#[derive(IntoElement)]
pub struct Transition {
id: ElementId,
shown: bool,
entrance: Entrance,
body: SmallVec<[AnyElement; 2]>,
}
impl Transition {
pub fn new(id: impl Into<ElementId>, shown: bool) -> Self {
Self {
id: id.into(),
shown,
entrance: Entrance::default(),
body: SmallVec::new(),
}
}
pub fn entrance(mut self, entrance: Entrance) -> Self {
self.entrance = entrance;
self
}
}
impl ParentElement for Transition {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
/// Moves `element` a share `t` of the way in: 0 is gone, 1 is in place.
pub(super) fn arrive<E: Styled>(element: E, entrance: Entrance, t: f32) -> E {
let t = ease_out_cubic(t);
match entrance {
Entrance::Fade => element.opacity(t),
Entrance::Rise => element.opacity(t).mt(NUDGE * 2.0 * (1.0 - t)),
Entrance::Slide => element.opacity(t).ml(NUDGE * -4.0 * (1.0 - t)),
}
}
impl RenderOnce for Transition {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let shown = self.shown;
let state = window.use_keyed_state((self.id.clone(), "turns"), cx, |_, _| Turns {
shown,
since: Instant::now(),
turn: 0,
});
if state.read(cx).shown != shown {
state.update(cx, |turns, _| {
turns.shown = shown;
turns.since = Instant::now();
turns.turn += 1;
});
}
let (since, turn) = (state.read(cx).since, state.read(cx).turn);
let length = duration(BASE, cx);
let leaving = !shown && turn > 0 && since.elapsed() < length;
if !shown && !leaving {
return div().into_any_element();
}
let share =
(since.elapsed().as_secs_f32() / length.as_secs_f32().max(f32::EPSILON)).min(1.0);
if turn > 0 && share < 1.0 {
window.request_animation_frame();
}
let content = div().children(self.body);
match turn {
0 => content.into_any_element(),
_ => arrive(
content,
self.entrance,
if shown { share } else { 1.0 - share },
)
.into_any_element(),
}
}
}
/// Children that enter one after another, `step` apart, each arriving once.
#[derive(IntoElement)]
pub struct Stagger {
id: ElementId,
step: Duration,
entrance: Entrance,
body: SmallVec<[AnyElement; 4]>,
}
impl Stagger {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
step: Duration::from_millis(60),
entrance: Entrance::Rise,
body: SmallVec::new(),
}
}
/// The gap between two children's starts.
pub fn step(mut self, step: Duration) -> Self {
self.step = step;
self
}
pub fn entrance(mut self, entrance: Entrance) -> Self {
self.entrance = entrance;
self
}
}
impl ParentElement for Stagger {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for Stagger {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let length = duration(BASE, cx);
let step = if cx.theme().reduced_motion {
Duration::ZERO
} else {
self.step
};
let (id, entrance) = (self.id, self.entrance);
div().children(self.body.into_iter().enumerate().map(move |(ix, child)| {
let lead = step * ix as u32;
let total = lead + length;
div().child(child).with_animation(
(id.clone(), format!("child-{ix}")),
Animation::new(total),
move |child, t| {
let at = (t * total.as_secs_f32() - lead.as_secs_f32()) / length.as_secs_f32();
arrive(child, entrance, at.clamp(0.0, 1.0))
},
)
}))
}
}
+12 -9
View File
@@ -25,16 +25,19 @@
- [x] RefreshIndicator → `motion::RefreshIndicator`
- [x] TypingIndicator → `motion::TypingIndicator`
## T11c Motion
## T11c Motion · Presence & order — `src/motion`
- [x] Transition — fade, slide, scale, collapse → `motion::Transition` fades, rises and slides; collapse is `layout::Collapsible`; scale is blocked: gpui 0.2.2 transforms only icons
- [x] AnimatePresence → `motion::AnimatePresence`
- [x] Motion → gpui's `with_animation`, timed by `motion::duration` and eased by the curves in `motion`
- [x] Spring → `motion::spring`
- [x] Stagger → `motion::Stagger`
- [x] Reorder → `motion::Reorder`
- [x] Flip → `motion::Flip`
- [x] CountUp → `typography::AnimatedNumber`, counting up
## T11d Motion · Effects
- [ ] Transition — fade, slide, scale, collapse
- [ ] AnimatePresence
- [ ] Motion
- [ ] Spring
- [ ] Stagger
- [ ] Reorder
- [ ] Flip
- [ ] CountUp → `typography::AnimatedNumber`
- [ ] Marquee
- [ ] Ripple
- [ ] Confetti