From 8e7e7ccace9fdd32d7b0a0402a5ed4ed303af2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 18:00:27 -0400 Subject: [PATCH] T11c motion: transitions, stagger, presence that folds, flip that glides, drag to reorder; motion keeps each row's identity --- AGENTS.md | 1 + TASKS.md | 5 +- examples/gallery/pages/motion/mod.rs | 16 ++ examples/gallery/pages/motion/moves.rs | 251 ++++++++++++++++++++++ src/motion/list.rs | 282 +++++++++++++++++++++++++ src/motion/mod.rs | 6 + src/motion/reorder.rs | 226 ++++++++++++++++++++ src/motion/tests.rs | 189 +++++++++++++++++ src/motion/transition.rs | 169 +++++++++++++++ tasks/ch11-20.md | 21 +- 10 files changed, 1156 insertions(+), 10 deletions(-) create mode 100644 examples/gallery/pages/motion/moves.rs create mode 100644 src/motion/list.rs create mode 100644 src/motion/reorder.rs create mode 100644 src/motion/transition.rs diff --git a/AGENTS.md b/AGENTS.md index 2ca10e1..20e09ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/TASKS.md b/TASKS.md index 8dd2e12..83084d3 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/pages/motion/mod.rs b/examples/gallery/pages/motion/mod.rs index 84e8562..785af02 100644 --- a/examples/gallery/pages/motion/mod.rs +++ b/examples/gallery/pages/motion/mod.rs @@ -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() } diff --git a/examples/gallery/pages/motion/moves.rs b/examples/gallery/pages/motion/moves.rs new file mode 100644 index 0000000..82d56d5 --- /dev/null +++ b/examples/gallery/pages/motion/moves.rs @@ -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, 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, 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 = 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 = 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 = 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.", + )) +} diff --git a/src/motion/list.rs b/src/motion/list.rs new file mode 100644 index 0000000..d7ad433 --- /dev/null +++ b/src/motion/list.rs @@ -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( + state: &Entity>, + 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) -> 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, 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::::new() + }); + let length = duration(BASE, cx); + let keys: Vec = 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; + +/// 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, +} + +impl AnimatePresence { + pub fn new(id: impl Into) -> 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, 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::::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) + } +} diff --git a/src/motion/mod.rs b/src/motion/mod.rs index 867bd21..77663c6 100644 --- a/src/motion/mod.rs +++ b/src/motion/mod.rs @@ -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}; diff --git a/src/motion/reorder.rs b/src/motion/reorder.rs new file mode 100644 index 0000000..993b00b --- /dev/null +++ b/src/motion/reorder.rs @@ -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; + +/// 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, +} + +impl Reorder { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + rows: Vec::new(), + on_reorder: None, + } + } + + pub fn row(mut self, key: impl Into, 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::); + let sizes = window.use_keyed_state((self.id.clone(), "heights"), cx, |_, _| { + HashMap::::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 = 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 = keys + .iter() + .map(|key| sizes.read(cx).get(key).copied().unwrap_or_default()) + .collect(); + let mut order: Vec = (0..keys.len()).collect(); + if let Some((from, target, _)) = held { + let row = order.remove(from); + order.insert(target, row); + } + let mut rows: Vec> = + 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::(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); + } +} diff --git a/src/motion/tests.rs b/src/motion/tests.rs index 8f195f7..789e49b 100644 --- a/src/motion/tests.rs +++ b/src/motion/tests.rs @@ -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>, + /// Each reorder row's keyed state, by row. + seen: Rc>>, + log: Vec, +} + +/// A 20px row that keeps keyed state and says which entity holds it. +#[derive(IntoElement)] +struct Marker { + key: &'static str, + seen: Rc>>, +} + +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) -> 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, &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"); +} diff --git a/src/motion/transition.rs b/src/motion/transition.rs new file mode 100644 index 0000000..be1d156 --- /dev/null +++ b/src/motion/transition.rs @@ -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, 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) { + self.body.extend(elements); + } +} + +/// Moves `element` a share `t` of the way in: 0 is gone, 1 is in place. +pub(super) fn arrive(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) -> 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) { + 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)) + }, + ) + })) + } +} diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index c80c257..885c16e 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -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