Skip to main content

gpui_ai/
motion.rs

1//! Motion primitives shared by gpui-ai components.
2//!
3//! Every effect here is built on GPUI's animation system, so reduced-motion
4//! mode resolves to a useful static frame automatically: one-shot reveals
5//! settle at their end state and repeating effects (shimmer, breathing)
6//! render at rest. Components opt in explicitly — nothing in this module
7//! installs idle redraw on its own.
8//!
9//! # The motion policy
10//!
11//! No component spells out a duration. Every animated timing in the crate
12//! resolves through [`MotionTokens`], one application-global policy that
13//! [`crate::init`] installs with the crate's defaults and an application may
14//! replace once, up front, for all components at a stroke.
15//!
16//! The policy speaks two vocabularies:
17//!
18//! - **Semantic roles**, the consumer-facing surface: [`MotionTokens::instant`],
19//!   [`MotionTokens::quick`], [`MotionTokens::standard`], and
20//!   [`MotionTokens::deliberate`] for durations, and the four spring roles —
21//!   press, selection, disclosure, reflow — as [`SpringRole`] response/damping
22//!   pairs. Components consume roles, never raw numbers.
23//! - **Named effects**, the crate-internal table: each shipped repeating
24//!   effect (shimmer, grid sweep, image pulse, status spinner, breathing, orb
25//!   lattice) keeps its own tempo entry, because collapsing six tuned tempos
26//!   into one "ambient" number would be a visual change wearing a refactor's
27//!   clothes. These readers stay `pub(crate)`.
28//!
29//! A progress loop is bound to work that ends: the element carrying it is
30//! gone once the work finishes. An ambient loop has no completion to report
31//! and runs for as long as its element is on screen. The instant role is
32//! zero by conviction, not omission: nothing in the crate delays or eases a
33//! direct response to input.
34//!
35//! Reduced motion outranks the policy. Every effect here routes through
36//! GPUI's animation system, whose reduced-motion contract no token value can
37//! override — a policy with ten-second durations still renders still frames
38//! when the platform asks for stillness.
39//!
40//! # Example
41//!
42//! ```no_run
43//! # use gpui_ai::prelude::*;
44//! # use gpui::ParentElement;
45//! # use gpui_component::v_flex;
46//! # fn example(is_running: bool, window: &mut gpui::Window, cx: &mut gpui::App) {
47//! use gpui_ai::motion::{Shimmer, reveal};
48//!
49//! // A "Thinking…" label with a travelling highlight while work runs.
50//! Shimmer::new("thinking-label", "Thinking…").active(is_running);
51//!
52//! // A freshly inserted row fades and rises into place once.
53//! reveal(v_flex().child("New tool call"), ("tool-call", 3_usize), window, cx);
54//! # }
55//! ```
56
57mod visibility;
58pub(crate) use visibility::VisibleAnimationExt;
59
60use gpui::{
61    Animation, AnimationElement, AnimationExt as _, App, ElementId, Global, Hsla, IntoElement,
62    ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window, div,
63    ease_in_out, pulsating_between, px, relative,
64};
65/// The crate's standard enter/exit curve: quint-class ease-out
66/// (`cubic-bezier(0.23, 1, 0.32, 1)`-equivalent). Leaves at full speed
67/// and spends most of its time settling, which is what makes a quick
68/// duration read as instant. The soft cubic stays available for gentle
69/// hover-class changes; deliberate entrances use this.
70pub(crate) fn ease_out_quint(t: f32) -> f32 {
71    let inverse = 1.0 - t.clamp(0.0, 1.0);
72    1.0 - inverse * inverse * inverse * inverse * inverse
73}
74
75/// Strong ease-in-out (quart-class,
76/// `cubic-bezier(0.77, 0, 0.175, 1)`-equivalent) for something already on
77/// screen travelling between two places — fills retargeting, the jump
78/// drive's glide.
79pub(crate) fn ease_in_out_quart(t: f32) -> f32 {
80    let t = t.clamp(0.0, 1.0);
81    if t < 0.5 {
82        8.0 * t * t * t * t
83    } else {
84        let inverse = 1.0 - t;
85        1.0 - 8.0 * inverse * inverse * inverse * inverse
86    }
87}
88use gpui_base::motion::{Transition, transition};
89use gpui_component::{ActiveTheme as _, StyledExt as _};
90use std::collections::HashMap;
91use std::time::Duration;
92#[cfg(not(target_family = "wasm"))]
93pub(crate) use std::time::Instant;
94#[cfg(target_family = "wasm")]
95pub(crate) use web_time::Instant;
96
97/// Width of the travelling shimmer highlight as a fraction of the label
98/// width. Highlight geometry rather than tempo, so it stays out of the role
99/// specs below.
100const SHIMMER_BAND: f32 = 0.45;
101
102/// A one-shot entrance: a newly mounted element settling into place.
103///
104/// One spec covers every entrance in the crate deliberately. Rows, chips,
105/// tool calls, and attachments arrive at one tempo; a second entrance tempo
106/// would be a design decision, not a configuration.
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub(crate) struct EnterSpec {
109    /// Mount to settled.
110    pub(crate) duration: Duration,
111    /// Added per sibling index, so a list ripples rather than snapping.
112    pub(crate) stagger: Duration,
113    /// Index past which the stagger stops growing, bounding how long the
114    /// last item of a long list waits.
115    pub(crate) stagger_cap: usize,
116    /// Travel from rest, in pixels. An animation distance, not layout.
117    pub(crate) rise: f32,
118}
119
120impl EnterSpec {
121    /// The crate's entrance tempo.
122    pub(crate) const REVEAL: Self = Self {
123        duration: Duration::from_millis(260),
124        stagger: Duration::from_millis(40),
125        stagger_cap: 12,
126        rise: 6.0,
127    };
128
129    /// The per-item delay of a staggered reveal, capped so long lists settle
130    /// in a bounded time.
131    pub(crate) fn stagger_delay(self, index: usize) -> Duration {
132        self.stagger * index.min(self.stagger_cap) as u32
133    }
134}
135
136/// A repeating loop that means "this work is in flight". It exists only
137/// while the work does: the caller stops rendering the element that carries
138/// it once the work finishes.
139#[derive(Debug, Clone, Copy, PartialEq)]
140pub(crate) struct ProgressLoopSpec {
141    /// One full pass, including any rest beat.
142    pub(crate) period: Duration,
143    /// Fraction of `period` spent moving. Below `1.0` the remainder is a
144    /// rest beat, so consecutive passes read as deliberate rather than
145    /// frantic.
146    pub(crate) duty: f32,
147}
148
149impl ProgressLoopSpec {
150    /// Label shimmer — the ecosystem's "something is happening" text
151    /// treatment.
152    pub(crate) const SHIMMER: Self = Self {
153        period: Duration::from_millis(1800),
154        duty: 0.72,
155    };
156
157    /// Diagonal sweep across the pixel-grid loader.
158    pub(crate) const GRID_SWEEP: Self = Self {
159        period: Duration::from_millis(1400),
160        duty: 1.0,
161    };
162
163    /// Placeholder pulse shown until generated pixels arrive.
164    pub(crate) const IMAGE_PULSE: Self = Self {
165        period: Duration::from_millis(1600),
166        duty: 1.0,
167    };
168
169    /// One rotation of an in-flight status icon.
170    pub(crate) const STATUS_SPINNER: Self = Self {
171        period: Duration::from_millis(900),
172        duty: 1.0,
173    };
174
175    /// This loop as a repeating GPUI animation.
176    pub(crate) fn looping(self) -> Animation {
177        repeating(self.period)
178    }
179
180    /// This loop as a repeating animation phase-locked to the application's
181    /// shared epoch, for a region clock whose siblings should tick together.
182    pub(crate) fn looping_synced(self) -> Animation {
183        repeating_synced(self.period)
184    }
185}
186
187/// A repeating loop with nothing to complete. It runs for as long as its
188/// element is on screen, which is the state it reports.
189#[derive(Debug, Clone, Copy, PartialEq)]
190pub(crate) struct AmbientLoopSpec {
191    /// One full cycle. Choreographed phase offsets are fractions of this.
192    pub(crate) period: Duration,
193}
194
195impl AmbientLoopSpec {
196    /// Opacity breathing for "still working" indicators.
197    pub(crate) const BREATHING: Self = Self {
198        period: Duration::from_millis(1600),
199    };
200
201    /// One cycle of the orb lattice.
202    pub(crate) const ORB_LATTICE: Self = Self {
203        period: Duration::from_millis(1700),
204    };
205
206    /// The cycle in whole milliseconds, for choreography that phases in
207    /// integer beats.
208    pub(crate) const fn period_millis(self) -> u64 {
209        self.period.as_millis() as u64
210    }
211
212    /// This loop as a repeating GPUI animation.
213    pub(crate) fn looping(self) -> Animation {
214        repeating(self.period)
215    }
216
217    /// This loop as a repeating animation phase-locked to the application's
218    /// shared epoch, for a region clock whose siblings should tick together.
219    pub(crate) fn looping_synced(self) -> Animation {
220        repeating_synced(self.period)
221    }
222}
223
224/// A loop over `period`.
225///
226/// Every repeating effect in the crate is built here, so all of them inherit
227/// GPUI's reduced-motion contract by construction: a repeating animation is
228/// held at its first frame and schedules nothing. No call site re-implements
229/// that check.
230fn repeating(period: Duration) -> Animation {
231    Animation::new(period).repeat()
232}
233
234/// A loop over `period`, phase-locked against `App`'s shared animation epoch
235/// rather than the element's own mount instant.
236///
237/// For region clocks: two clusters mounted at different moments sample the
238/// same phase, so side-by-side instances tick together instead of beating
239/// against each other. The reduced-motion contract is unchanged — a held
240/// animation is held whichever clock it reads.
241fn repeating_synced(period: Duration) -> Animation {
242    Animation::new(period).repeat_synced()
243}
244
245/// How much of the described motion actually runs.
246///
247/// Resolution follows the crate's customization order: this default → the
248/// application's installed policy → per-surface overrides where a
249/// component exposes one. The OS reduce-motion signal composes with the
250/// policy and can only restrict: under reduce motion the effective
251/// preference is at least [`Crossfade`](Self::Crossfade), never less —
252/// the policy may choose [`Snap`](Self::Snap), but nothing wins motion
253/// back from the OS signal.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
255pub enum MotionPreference {
256    /// Everything the policy describes: travel, springs, and loops.
257    #[default]
258    Full,
259    /// Comprehension-aiding opacity only, at the quick tempo: no travel,
260    /// no springs, no stagger delays, and no repeating loops beyond
261    /// progress indication.
262    Crossfade,
263    /// State changes land in one frame.
264    Snap,
265}
266
267/// A spring's feel, as the pair the ecosystem tunes springs by: the response
268/// period and the damping ratio.
269///
270/// `response` is the period one full oscillation would take undamped — the
271/// scale the motion is felt at, not the moment it stops. `damping` is the
272/// ratio ζ: `1.0` approaches without overshoot, below it passes the target
273/// and comes back, above it approaches slowly. These are the two numbers
274/// `gpui_base::motion::Spring` is built from, so a role converts to a
275/// running spring without translation.
276#[derive(Debug, Clone, Copy, PartialEq)]
277pub struct SpringRole {
278    response: Duration,
279    damping: f32,
280}
281
282impl SpringRole {
283    const fn new(response: Duration, damping: f32) -> Self {
284        Self { response, damping }
285    }
286
287    /// The response period the role is felt at.
288    pub const fn response(self) -> Duration {
289        self.response
290    }
291
292    /// The damping ratio ζ; `1.0` means no overshoot.
293    pub const fn damping(self) -> f32 {
294        self.damping
295    }
296}
297
298/// The application-global motion policy: every duration, stagger beat, and
299/// spring response the crate animates with, resolved through one value.
300///
301/// [`crate::init`] installs [`MotionTokens::DEFAULT`] unless the application
302/// already chose. To customize, build from `default()` and [`set`](Self::set)
303/// the result — before windows render, so no frame is timed under two
304/// policies:
305///
306/// ```no_run
307/// # use gpui_ai::prelude::*;
308/// # use std::time::Duration;
309/// # fn example(cx: &mut gpui::App) {
310/// gpui_ai::init(cx);
311/// gpui_ai::motion::MotionTokens::default()
312///     .with_quick(Duration::from_millis(120))
313///     .set(cx);
314/// # }
315/// ```
316///
317/// Components use the quick role for acknowledgments, standard for entrances
318/// and compact disclosures, and spring roles for bounded retargets. Named
319/// repeating effects keep their individual tempos. The deliberate role is
320/// available for application composition without adding motion implicitly.
321///
322/// What the policy cannot do: override reduced motion. `cx.reduce_motion()`
323/// stays authoritative in every consumer, so customization tunes tempo and
324/// feel, never whether travel happens for someone who asked for stillness.
325#[derive(Debug, Clone, PartialEq)]
326pub struct MotionTokens {
327    preference: MotionPreference,
328    instant: Duration,
329    quick: Duration,
330    hover_glide: Duration,
331    standard: Duration,
332    deliberate: Duration,
333    stagger_beat: Duration,
334    stagger_cap: usize,
335    press: SpringRole,
336    selection: SpringRole,
337    disclosure: SpringRole,
338    reflow: SpringRole,
339    shimmer: ProgressLoopSpec,
340    grid_sweep: ProgressLoopSpec,
341    image_pulse: ProgressLoopSpec,
342    status_spinner: ProgressLoopSpec,
343    breathing: AmbientLoopSpec,
344    orb_lattice: AmbientLoopSpec,
345}
346
347impl Global for MotionTokens {}
348
349impl Default for MotionTokens {
350    fn default() -> Self {
351        Self::DEFAULT
352    }
353}
354
355/// How many items may take part in one arrival cascade. The bound is the
356/// bounded-choreography rule, not a tunable: a longer list arrives with the
357/// last beat rather than queueing behind it.
358const STAGGER_PARTICIPANTS: usize = 6;
359
360/// The most aggregate delay one cascade may spread across its participants.
361const STAGGER_TOTAL_CAP: Duration = Duration::from_millis(220);
362
363impl MotionTokens {
364    /// The crate's default duration, spring, and repeating-effect policy.
365    pub const DEFAULT: Self = Self {
366        preference: MotionPreference::Full,
367        instant: Duration::ZERO,
368        quick: Duration::from_millis(150),
369        // Bench-tuned: 150ms read as lag behind the pointer; 100ms tracks.
370        hover_glide: Duration::from_millis(100),
371        standard: EnterSpec::REVEAL.duration,
372        deliberate: Duration::from_millis(380),
373        stagger_beat: EnterSpec::REVEAL.stagger,
374        stagger_cap: EnterSpec::REVEAL.stagger_cap,
375        press: SpringRole::new(Duration::from_millis(140), 1.0),
376        selection: SpringRole::new(Duration::from_millis(220), 1.0),
377        disclosure: SpringRole::new(Duration::from_millis(280), 1.0),
378        // The response the shipped FilterTable reorder proved in 0.2.x,
379        // adopted as the role's value when that reorder became the role's
380        // first consumer.
381        reflow: SpringRole::new(Duration::from_millis(180), 1.0),
382        shimmer: ProgressLoopSpec::SHIMMER,
383        grid_sweep: ProgressLoopSpec::GRID_SWEEP,
384        image_pulse: ProgressLoopSpec::IMAGE_PULSE,
385        status_spinner: ProgressLoopSpec::STATUS_SPINNER,
386        breathing: AmbientLoopSpec::BREATHING,
387        orb_lattice: AmbientLoopSpec::ORB_LATTICE,
388    };
389
390    /// The policy the application installed, or the crate's default where it
391    /// installed none. Never panics: a window rendered before `init` runs is
392    /// timed by [`Self::DEFAULT`], the same values `init` would install.
393    pub fn read(cx: &App) -> &Self {
394        cx.try_global::<Self>().unwrap_or(&Self::DEFAULT)
395    }
396
397    /// Makes this policy the application's, for every component at once.
398    pub fn set(self, cx: &mut App) {
399        cx.set_global(self);
400    }
401
402    // -- Duration roles ----------------------------------------------------
403
404    /// Focus, press acknowledgement, pointer capture: zero, because a direct
405    /// response to input is not eased.
406    pub const fn instant(&self) -> Duration {
407        self.instant
408    }
409
410    /// Icon or label swap, copy acknowledgement, close.
411    pub const fn quick(&self) -> Duration {
412        self.quick
413    }
414
415    /// One hover highlight gliding between uniform rows, chasing the
416    /// pointer on the strong ease-out. Faster than [`quick`](Self::quick)
417    /// because the highlight follows input rather than acknowledging it;
418    /// any reduced preference snaps the highlight instead.
419    pub const fn hover_glide(&self) -> Duration {
420        self.hover_glide
421    }
422
423    /// Selected indicator, compact disclosure, progress retarget — and the
424    /// crate's entrance tempo.
425    pub const fn standard(&self) -> Duration {
426        self.standard
427    }
428
429    /// Larger bounded panel or success choreography.
430    ///
431    /// Restricted by contract to modal- and sheet-class surfaces and
432    /// explanatory composition: nothing smaller — tooltips, popovers,
433    /// dropdowns, toggles, row state — may bind to it, because 380ms on
434    /// an element the reader is waiting on reads as lag, not weight.
435    /// Popover-class entrances bind to [`quick`](Self::quick).
436    pub const fn deliberate(&self) -> Duration {
437        self.deliberate
438    }
439
440    // -- Spring roles ------------------------------------------------------
441
442    /// Reversible control compression: fast and fully damped.
443    pub const fn press_spring(&self) -> SpringRole {
444        self.press
445    }
446
447    /// An active pill or marker gliding between targets.
448    pub const fn selection_spring(&self) -> SpringRole {
449        self.selection
450    }
451
452    /// Bounded panel geometry; no bounce, so it also suits destructive and
453    /// error surfaces.
454    pub const fn disclosure_spring(&self) -> SpringRole {
455        self.disclosure
456    }
457
458    /// Visible row displacement: critically damped, velocity-preserving.
459    pub const fn reflow_spring(&self) -> SpringRole {
460        self.reflow
461    }
462
463    // -- Builders ----------------------------------------------------------
464
465    /// The policy's own motion preference, before the OS signal composes.
466    pub const fn preference(&self) -> MotionPreference {
467        self.preference
468    }
469
470    /// The preference actually in effect: the policy's, floored at
471    /// [`MotionPreference::Crossfade`] while the OS asks for reduced
472    /// motion. Every clock, spring, and loop in the crate resolves
473    /// through this.
474    pub fn effective_preference(cx: &App) -> MotionPreference {
475        let policy = Self::read(cx).preference;
476        if cx.reduce_motion() {
477            policy.max(MotionPreference::Crossfade)
478        } else {
479            policy
480        }
481    }
482
483    /// Replaces the [`preference`](Self::preference).
484    pub const fn with_preference(mut self, preference: MotionPreference) -> Self {
485        self.preference = preference;
486        self
487    }
488
489    /// Replaces the [`instant`](Self::instant) duration.
490    pub const fn with_instant(mut self, duration: Duration) -> Self {
491        self.instant = duration;
492        self
493    }
494
495    /// Replaces the [`quick`](Self::quick) duration.
496    pub const fn with_quick(mut self, duration: Duration) -> Self {
497        self.quick = duration;
498        self
499    }
500
501    /// Replaces the [`hover_glide`](Self::hover_glide) duration.
502    pub const fn with_hover_glide(mut self, duration: Duration) -> Self {
503        self.hover_glide = duration;
504        self
505    }
506
507    /// Replaces the [`standard`](Self::standard) duration, which is also the
508    /// entrance tempo every reveal in the crate settles at.
509    pub const fn with_standard(mut self, duration: Duration) -> Self {
510        self.standard = duration;
511        self
512    }
513
514    /// Replaces the [`deliberate`](Self::deliberate) duration.
515    pub const fn with_deliberate(mut self, duration: Duration) -> Self {
516        self.deliberate = duration;
517        self
518    }
519
520    /// Replaces the per-item stagger beat. The participation and aggregate
521    /// caps are policy, not configuration, and stay where they are.
522    pub const fn with_stagger_beat(mut self, beat: Duration) -> Self {
523        self.stagger_beat = beat;
524        self
525    }
526
527    /// Replaces the press spring's feel.
528    pub const fn with_press_spring(mut self, response: Duration, damping: f32) -> Self {
529        self.press = SpringRole::new(response, damping);
530        self
531    }
532
533    /// Replaces the selection spring's feel.
534    pub const fn with_selection_spring(mut self, response: Duration, damping: f32) -> Self {
535        self.selection = SpringRole::new(response, damping);
536        self
537    }
538
539    /// Replaces the disclosure spring's feel.
540    pub const fn with_disclosure_spring(mut self, response: Duration, damping: f32) -> Self {
541        self.disclosure = SpringRole::new(response, damping);
542        self
543    }
544
545    /// Replaces the reflow spring's feel.
546    pub const fn with_reflow_spring(mut self, response: Duration, damping: f32) -> Self {
547        self.reflow = SpringRole::new(response, damping);
548        self
549    }
550
551    // -- Crate-internal readers --------------------------------------------
552
553    /// The entrance spec every reveal resolves through: the standard tempo,
554    /// the stagger beat and cap, and the crate's fixed travel distance.
555    pub(crate) const fn reveal(&self) -> EnterSpec {
556        EnterSpec {
557            duration: self.standard,
558            stagger: self.stagger_beat,
559            stagger_cap: self.stagger_cap,
560            rise: EnterSpec::REVEAL.rise,
561        }
562    }
563
564    /// Label shimmer tempo.
565    pub(crate) const fn shimmer(&self) -> ProgressLoopSpec {
566        self.shimmer
567    }
568
569    /// Pixel-grid loader sweep tempo.
570    pub(crate) const fn grid_sweep(&self) -> ProgressLoopSpec {
571        self.grid_sweep
572    }
573
574    /// Image placeholder pulse tempo.
575    pub(crate) const fn image_pulse(&self) -> ProgressLoopSpec {
576        self.image_pulse
577    }
578
579    /// In-flight status icon rotation tempo.
580    pub(crate) const fn status_spinner(&self) -> ProgressLoopSpec {
581        self.status_spinner
582    }
583
584    /// Opacity-breathing tempo.
585    pub(crate) const fn breathing(&self) -> AmbientLoopSpec {
586        self.breathing
587    }
588
589    /// Orb lattice cycle tempo.
590    pub(crate) const fn orb_lattice(&self) -> AmbientLoopSpec {
591        self.orb_lattice
592    }
593
594    /// The delay item `index` of `arriving` newly visible items waits before
595    /// its arrival plays — the stagger role.
596    ///
597    /// The cascade decelerates: delays are spread with ease-out spacing, so
598    /// later items land closer together and the group resolves quickly
599    /// instead of trailing. At most six items take part, the whole cascade
600    /// spends at most 220 ms, and items past the participation bound arrive
601    /// with the last beat rather than after it — the bounded-choreography
602    /// caps, which are policy rather than configuration. The per-item beat
603    /// scales with [`with_stagger_beat`](Self::with_stagger_beat).
604    ///
605    /// Provisional like the other unconsumed roles: the motion lab is its
606    /// first consumer and its validator, and the shipped `reveal_staggered`
607    /// keeps its documented linear beats regardless.
608    pub fn arrival_stagger(&self, index: usize, arriving: usize) -> Duration {
609        let last = arriving.min(STAGGER_PARTICIPANTS).saturating_sub(1);
610        if last == 0 {
611            return Duration::ZERO;
612        }
613        let natural = self.stagger_beat * last as u32;
614        let total = natural.min(STAGGER_TOTAL_CAP);
615        let position = index.min(last) as f32 / last as f32;
616        // Ease-out spacing: the derivative shrinks as position grows, so
617        // each gap is smaller than the one before it.
618        let eased = position * (2.0 - position);
619        total.mul_f32(eased)
620    }
621}
622
623/// Installs [`MotionTokens::DEFAULT`] unless the application already chose a
624/// policy — so `init` never overwrites a customization made before it ran,
625/// and a customization made after simply replaces the default.
626pub(crate) fn install(cx: &mut App) {
627    if !cx.has_global::<MotionTokens>() {
628        cx.set_global(MotionTokens::DEFAULT);
629    }
630}
631
632/// The open/close channel every animated disclosure in the crate samples.
633///
634/// One retargetable transition from closed (`0.0`) to open (`1.0`) on the
635/// standard role: rapid toggling resumes from the current sample rather than
636/// restarting, content changes leave a settled channel untouched, and
637/// reduced motion snaps to the target — GPUI's transition contract, not
638/// per-component policy. Callers cross-fade their body on the returned
639/// progress (opacity plus a small token-derived lift) while open. Closed
640/// bodies leave the tree immediately: opacity does not suppress focus,
641/// input, or accessibility. Headers can retain the closing transition.
642/// Callers never animate the body's height,
643/// because rich content re-measured per frame is the layout loop the motion
644/// plan forbids.
645///
646/// Frame demand: only while the channel is travelling. Settled open it
647/// requests nothing; settled closed the caller unmounts the body.
648pub(crate) fn disclosure_progress(
649    id: impl Into<gpui_base::motion::TransitionId>,
650    open: bool,
651    window: &mut Window,
652    cx: &mut App,
653) -> f32 {
654    let target = if open { 1.0f32 } else { 0.0 };
655    if !motion_is_full(cx) {
656        // Geometry is travel: a reduced preference lands the surface in
657        // one frame and leaves the fade to `disclosure_fade`.
658        return target;
659    }
660    let standard = MotionTokens::read(cx).standard();
661    transition(
662        id,
663        target,
664        Transition::new(standard).ease(ease_out_quint),
665        window,
666        cx,
667    )
668}
669
670/// Reveals a disclosure body by easing its height, not just its opacity.
671///
672/// A body that appears at full height and merely fades reads as a snap
673/// however long the fade takes — the 0.4.0 feel review's first finding.
674/// This clips the body to a fraction of its own measured height, so the
675/// surface grows into the space it is about to occupy.
676///
677/// The body's natural height is measured from the content itself, which
678/// keeps its full size inside the clip; the measurement writes no
679/// notification, so a settled disclosure schedules nothing. Until a first
680/// measurement exists the body renders unclipped, which is what a
681/// reduced preference wants anyway: `progress` is already 1.0 there.
682pub(crate) fn disclosure_clip(
683    id: impl Into<ElementId>,
684    progress: f32,
685    content: impl gpui::IntoElement,
686    window: &mut Window,
687    cx: &mut App,
688) -> gpui::Div {
689    use gpui::prelude::FluentBuilder as _;
690    use gpui::{ParentElement as _, Styled as _};
691    use gpui_base::ElementExt as _;
692
693    let id = id.into();
694    let measured = window.use_keyed_state((id.clone(), "disclosure-height"), cx, |_, _| {
695        None::<gpui::Pixels>
696    });
697    let height = *measured.read(cx);
698    let clipping = progress < 1.0;
699    // A body that unmounts while closed is measured again on the way back,
700    // and the measuring pass happens during prepaint — after this render
701    // has already sized the clip. Until a height is known, a clipping body
702    // is held closed rather than drawn at its full size: opening would
703    // otherwise flash the whole body for one frame and then collapse it,
704    // which is a worse pop than the one this replaced.
705    let clipped = match (height, clipping) {
706        (Some(height), true) => Some(height * progress.max(0.0)),
707        (None, true) => Some(gpui::Pixels::ZERO),
708        _ => None,
709    };
710    let recorder = measured;
711    gpui::div()
712        .w_full()
713        .overflow_hidden()
714        .when_some(clipped, |body, height| body.h(height))
715        .child(
716            gpui::div()
717                .w_full()
718                .flex_none()
719                .on_prepaint(move |bounds, _, cx| {
720                    // The content keeps its natural height inside the clip,
721                    // so this reads the same number whether the body is
722                    // mid-reveal or settled — and a body that unmounts
723                    // while closed measures itself again on the way back.
724                    // Geometry bookkeeping only: no notify, so a settled
725                    // disclosure schedules nothing for having been measured.
726                    recorder.update(cx, |measured, _| *measured = Some(bounds.size.height));
727                })
728                .child(content),
729        )
730}
731
732/// The opacity half of a disclosure: identical to
733/// [`disclosure_progress`] under full motion (same transition key, so the
734/// two never disagree), a quick crossfade on its own clock under the
735/// crossfade preference — the upstream transition would snap it whenever
736/// the OS flag is set — and the target under snap.
737pub(crate) fn disclosure_fade(
738    id: impl Into<gpui_base::motion::TransitionId>,
739    open: bool,
740    window: &mut Window,
741    cx: &mut App,
742) -> f32 {
743    let target = if open { 1.0f32 } else { 0.0 };
744    match MotionTokens::effective_preference(cx) {
745        MotionPreference::Snap => target,
746        MotionPreference::Crossfade => {
747            let id = id.into();
748            crossfade_toward(
749                ElementId::Name(format!("{id:?}-disclosure-fade").into()),
750                target,
751                window,
752                cx,
753            )
754        }
755        MotionPreference::Full => disclosure_progress(id, open, window, cx),
756    }
757}
758
759/// Arrival bookkeeping for one owner. History starts at rest; later batches
760/// animate at most six current identities. The owner retains the clocks, so
761/// temporarily unmounting a child cannot restart its arrival. Removed items
762/// and settled clocks are retired instead of accumulating lifetime history.
763pub(crate) struct ArrivalRoster {
764    primed: bool,
765    epoch: bool,
766    seen: HashMap<ElementId, bool>,
767    delays: HashMap<ElementId, ArrivalTiming>,
768}
769
770struct ArrivalTiming {
771    started_at: Instant,
772    delay: Duration,
773    duration: Duration,
774}
775
776impl ArrivalTiming {
777    fn progress(&self, now: Instant) -> f32 {
778        let elapsed = now.saturating_duration_since(self.started_at);
779        if self.duration.is_zero() {
780            1.0
781        } else if elapsed < self.delay {
782            0.0
783        } else {
784            (elapsed.saturating_sub(self.delay).as_secs_f32() / self.duration.as_secs_f32())
785                .min(1.0)
786        }
787    }
788}
789
790impl ArrivalRoster {
791    pub(crate) fn new() -> Self {
792        Self {
793            primed: false,
794            epoch: false,
795            seen: HashMap::new(),
796            delays: HashMap::new(),
797        }
798    }
799
800    /// Takes the roll call of this render's identities, assigning one
801    /// decelerating cascade to those not seen before when `assign` holds.
802    pub(crate) fn note(
803        &mut self,
804        keys: impl Iterator<Item = ElementId>,
805        assign: bool,
806        tokens: &MotionTokens,
807        now: Instant,
808    ) {
809        self.epoch = !self.epoch;
810        let mut fresh = Vec::new();
811        for key in keys {
812            if self.seen.insert(key.clone(), self.epoch).is_none()
813                && fresh.len() < STAGGER_PARTICIPANTS
814            {
815                fresh.push(key);
816            }
817        }
818        self.seen.retain(|_, epoch| *epoch == self.epoch);
819        self.delays.retain(|key, timing| {
820            assign && self.seen.contains_key(key) && timing.progress(now) < 1.0
821        });
822        let primed = std::mem::replace(&mut self.primed, true);
823        let cascade = primed && assign;
824        fresh.truncate(STAGGER_PARTICIPANTS.saturating_sub(self.delays.len()));
825        let batch = fresh.len();
826        for (position, key) in fresh.into_iter().enumerate() {
827            if cascade {
828                self.delays.insert(
829                    key,
830                    ArrivalTiming {
831                        started_at: now,
832                        delay: tokens.arrival_stagger(position, batch),
833                        duration: tokens.standard(),
834                    },
835                );
836            }
837        }
838    }
839
840    /// Samples the owner's clock, retiring it on completion or reduced
841    /// motion. Only a live sample asks for another frame.
842    pub(crate) fn progress(
843        &mut self,
844        key: &ElementId,
845        window: &mut Window,
846        cx: &App,
847    ) -> Option<f32> {
848        let progress = self
849            .delays
850            .get(key)?
851            .progress(cx.background_executor().now());
852        if MotionTokens::effective_preference(cx) == MotionPreference::Snap || progress >= 1.0 {
853            self.delays.remove(key);
854            return None;
855        }
856        note_reveal_frame_request();
857        window.request_animation_frame();
858        Some(ease_out_quint(progress))
859    }
860
861    #[cfg(test)]
862    pub(crate) fn delay(&self, key: &ElementId) -> Option<Duration> {
863        self.delays.get(key).map(|timing| timing.delay)
864    }
865}
866
867/// Text with a soft highlight travelling across it — the ecosystem's
868/// "something is happening" label treatment for thinking, running tool
869/// groups, and streaming plans.
870///
871/// The base layer is muted text; a clipped bright copy sweeps over it. When
872/// [`Shimmer::active`] is false (or reduced motion is on) the label renders
873/// as plain muted text, so meaning never depends on the motion.
874#[derive(IntoElement)]
875pub struct Shimmer {
876    id: ElementId,
877    style: StyleRefinement,
878    text: SharedString,
879    active: bool,
880    base: Option<Hsla>,
881    highlight: Option<Hsla>,
882}
883
884impl Shimmer {
885    /// Creates an active shimmer label.
886    pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
887        Self {
888            id: id.into(),
889            style: StyleRefinement::default(),
890            text: text.into(),
891            active: true,
892            base: None,
893            highlight: None,
894        }
895    }
896
897    /// Sets whether the highlight travels. Inactive labels are plain text.
898    pub fn active(mut self, active: bool) -> Self {
899        self.active = active;
900        self
901    }
902
903    /// Overrides the resting and highlight colors (defaults: muted foreground
904    /// and foreground).
905    pub fn colors(mut self, base: Hsla, highlight: Hsla) -> Self {
906        self.base = Some(base);
907        self.highlight = Some(highlight);
908        self
909    }
910}
911
912impl Styled for Shimmer {
913    fn style(&mut self) -> &mut StyleRefinement {
914        &mut self.style
915    }
916}
917
918// Travel during the first part of the cycle, then rest fully off the trailing
919// edge. The start is relative to the label width, as is SHIMMER_BAND.
920fn shimmer_band_start(delta: f32, duty: f32) -> f32 {
921    let progress = ease_in_out((delta / duty).min(1.0));
922    -SHIMMER_BAND + progress * (1.0 + SHIMMER_BAND)
923}
924
925impl RenderOnce for Shimmer {
926    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
927        let base = self.base.unwrap_or(cx.theme().muted_foreground);
928        let highlight = self.highlight.unwrap_or(cx.theme().foreground);
929        if !self.active || !motion_is_full(cx) {
930            return div()
931                .whitespace_nowrap()
932                .text_color(base)
933                .child(self.text)
934                .refine_style(&self.style)
935                .into_any_element();
936        }
937
938        let text = self.text;
939        let spec = MotionTokens::read(cx).shimmer();
940        div()
941            .relative()
942            .overflow_hidden()
943            .whitespace_nowrap()
944            .text_color(base)
945            .child(text.clone())
946            .refine_style(&self.style)
947            .with_visible_animation(
948                (self.id, "shimmer"),
949                // Frame demand: active while the caller's work is in flight,
950                // which is what `active` reports; the caller stops passing
951                // `active(true)` when the work ends, so the loop never runs
952                // over a settled surface. Reduced motion holds delta at 0 —
953                // the band sits fully off the leading edge and the label is
954                // plain muted text.
955                spec.looping(),
956                move |container, delta| {
957                    let start = shimmer_band_start(delta, spec.duty);
958                    container.child(
959                        div()
960                            .absolute()
961                            .top_0()
962                            .bottom_0()
963                            .left(relative(start))
964                            .w(relative(SHIMMER_BAND))
965                            .overflow_hidden()
966                            .child(
967                                div()
968                                    .absolute()
969                                    .top_0()
970                                    // Counter-offset so the bright copy stays
971                                    // aligned with the base text underneath.
972                                    .left(relative(-start / SHIMMER_BAND))
973                                    .whitespace_nowrap()
974                                    .text_color(highlight)
975                                    .child(text.clone()),
976                            ),
977                    )
978                },
979            )
980            .into_any_element()
981    }
982}
983
984/// Progress of a one-shot reveal keyed by `id`, from `0.0` (just mounted) to
985/// `1.0` (settled), starting the clock the first time the element renders.
986///
987/// The start instant lives in keyed element state, so a row that keeps its
988/// stable identity across renders plays the reveal only once; it requests
989/// animation frames until it settles and returns `1.0` immediately under
990/// reduced motion.
991pub fn reveal_progress(
992    id: impl Into<ElementId>,
993    delay: Duration,
994    window: &mut Window,
995    cx: &mut App,
996) -> f32 {
997    let duration = MotionTokens::read(cx).reveal().duration;
998    timed_progress(id, delay, duration, window, cx)
999}
1000
1001/// One-shot decay clock for a released press, over the press spring's
1002/// response. Runs 0→1 from the first frame its key exists; callers paint
1003/// the press tint at `1.0 - progress`, so a fresh key replays the release
1004/// once and re-renders replay nothing. Pressing itself never rides this —
1005/// the pressed style applies on pointer-down, instantly, and only the way
1006/// back out is staged.
1007pub(crate) fn press_release_progress(
1008    id: impl Into<ElementId>,
1009    window: &mut Window,
1010    cx: &mut App,
1011) -> f32 {
1012    let response = MotionTokens::read(cx).press_spring().response();
1013    timed_progress(id, Duration::ZERO, response, window, cx)
1014}
1015
1016/// Progress of a one-shot status swap keyed by `id`, at the quick role's
1017/// tempo — the acknowledgment an icon or label change settles in on.
1018pub(crate) fn swap_progress(id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> f32 {
1019    let duration = MotionTokens::read(cx).quick();
1020    timed_progress(id, Duration::ZERO, duration, window, cx)
1021}
1022
1023/// One-shot acknowledgment of the state a fixed slot has settled into.
1024///
1025/// Observe the slot on every render, including states that do not apply the
1026/// returned opacity. The initial state is settled; each subsequent change
1027/// starts one quick acknowledgment, including a return to the initial state.
1028/// One retained slot owns the clock rather than allocating a key per value.
1029/// Running faces may ignore the sample so their spinner remains unmodified.
1030pub(crate) fn acknowledged_state(
1031    slot: ElementId,
1032    ordinal: u64,
1033    window: &mut Window,
1034    cx: &mut App,
1035) -> f32 {
1036    let now = cx.background_executor().now();
1037    let duration = MotionTokens::read(cx).quick();
1038    let reduced =
1039        MotionTokens::effective_preference(cx) == MotionPreference::Snap || duration.is_zero();
1040    let state =
1041        window.use_keyed_state((slot, "acknowledged-state"), cx, |_, _| AcknowledgedState {
1042            ordinal,
1043            started_at: None,
1044        });
1045    let progress = state.update(cx, |state, _| {
1046        if state.ordinal != ordinal {
1047            state.ordinal = ordinal;
1048            state.started_at = Some(now);
1049        }
1050        if reduced {
1051            state.started_at = None;
1052        }
1053        let progress = state.started_at.map_or(1.0, |started| {
1054            (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0)
1055        });
1056        if progress >= 1.0 {
1057            state.started_at = None;
1058        }
1059        progress
1060    });
1061    if progress < 1.0 {
1062        note_reveal_frame_request();
1063        window.request_animation_frame();
1064    }
1065    ease_out_quint(progress)
1066}
1067
1068struct AcknowledgedState {
1069    ordinal: u64,
1070    started_at: Option<Instant>,
1071}
1072
1073/// `1.0` while travel is allowed, `0.0` under crossfade and snap.
1074///
1075/// Multiplied into every offset a progress value drives, so the same
1076/// sample fades under a reduced preference instead of travelling — the
1077/// "gentler, not zero" reading both review standards prescribe.
1078pub(crate) fn travel(cx: &App) -> f32 {
1079    if MotionTokens::effective_preference(cx) == MotionPreference::Full {
1080        1.0
1081    } else {
1082        0.0
1083    }
1084}
1085
1086/// Whether springs, reorders, drives, and decorative loops may run at
1087/// all. Progress indication (an indeterminate spinner) is exempt from
1088/// crossfade by contract and follows only snap and the OS signal.
1089pub(crate) fn motion_is_full(cx: &App) -> bool {
1090    MotionTokens::effective_preference(cx) == MotionPreference::Full
1091}
1092
1093/// The duration a geometry retarget (fills, widths, disclosure travel)
1094/// actually animates over: unchanged under full motion, zero otherwise —
1095/// geometry is travel, and gpui_base's transition snaps on a zero
1096/// duration.
1097pub(crate) fn retarget_duration(cx: &App, duration: Duration) -> Duration {
1098    if motion_is_full(cx) {
1099        duration
1100    } else {
1101        Duration::ZERO
1102    }
1103}
1104
1105/// A crossfade's own retargeting clock: steps `value` toward `target` at
1106/// the quick tempo, carrying the current value through interruptions.
1107///
1108/// The upstream transition facility snaps whenever the OS asks for
1109/// reduced motion — correct for travel, wrong for the opacity fades the
1110/// crossfade preference exists to keep. This clock ignores the OS flag
1111/// on purpose; callers reach it only through a preference match that has
1112/// already resolved to crossfade.
1113fn crossfade_toward(
1114    id: impl Into<ElementId>,
1115    target: f32,
1116    window: &mut Window,
1117    cx: &mut App,
1118) -> f32 {
1119    let quick = MotionTokens::read(cx)
1120        .quick()
1121        .as_secs_f32()
1122        .max(f32::EPSILON);
1123    let now = cx.background_executor().now();
1124    let state = window.use_keyed_state((id.into(), "crossfade"), cx, |_, _| CrossfadeState {
1125        value: target,
1126        at: now,
1127    });
1128    let value = state.update(cx, |state, _| {
1129        let step = now.saturating_duration_since(state.at).as_secs_f32() / quick;
1130        state.at = now;
1131        state.value = if state.value < target {
1132            (state.value + step).min(target)
1133        } else {
1134            (state.value - step).max(target)
1135        };
1136        state.value
1137    });
1138    if (value - target).abs() > f32::EPSILON {
1139        note_reveal_frame_request();
1140        window.request_animation_frame();
1141    }
1142    value
1143}
1144
1145struct CrossfadeState {
1146    value: f32,
1147    at: Instant,
1148}
1149
1150/// The shared clock behind [`reveal_progress`] and [`swap_progress`]: `0.0`
1151/// at the keyed instant the element first rendered, `1.0` once `duration`
1152/// (after `delay`) has passed, eased on the way.
1153fn timed_progress(
1154    id: impl Into<ElementId>,
1155    delay: Duration,
1156    duration: Duration,
1157    window: &mut Window,
1158    cx: &mut App,
1159) -> f32 {
1160    // Frame demand: the only hand-scheduled effects in the crate, because
1161    // these read a clock rather than a GPUI animation. Active while
1162    // `progress < 1.0`; a settled clock asks for nothing. Snap returns
1163    // the end state; crossfade keeps the fade — clamped to the quick
1164    // tempo, stagger delay dropped — because a one-shot opacity change is
1165    // exactly the comprehension aid a reduced preference retains.
1166    let (delay, duration) = match MotionTokens::effective_preference(cx) {
1167        MotionPreference::Snap => return 1.0,
1168        MotionPreference::Crossfade => {
1169            (Duration::ZERO, duration.min(MotionTokens::read(cx).quick()))
1170        }
1171        MotionPreference::Full => (delay, duration),
1172    };
1173    let now = cx.background_executor().now();
1174    let started = *window.use_keyed_state(id, cx, |_, _| now).read(cx);
1175    let elapsed = now.saturating_duration_since(started);
1176    let progress = if elapsed <= delay {
1177        0.0
1178    } else {
1179        (elapsed.saturating_sub(delay).as_secs_f32() / duration.as_secs_f32()).min(1.0)
1180    };
1181    if progress < 1.0 {
1182        note_reveal_frame_request();
1183        window.request_animation_frame();
1184    }
1185    ease_out_quint(progress)
1186}
1187
1188#[cfg(test)]
1189thread_local! {
1190    /// Animation frames reveals have asked for on this thread.
1191    ///
1192    /// Thread-local rather than global: the harness runs each test on its own
1193    /// thread, and a shared counter would report another test's frames.
1194    static REVEAL_FRAME_REQUESTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1195}
1196
1197/// Records one animation frame requested by a reveal.
1198///
1199/// Frame demand is the property under audit, so it is counted at the single
1200/// site that creates it instead of inferred from progress values. Nothing
1201/// outside tests compiles the counter.
1202#[inline]
1203fn note_reveal_frame_request() {
1204    #[cfg(test)]
1205    REVEAL_FRAME_REQUESTS.with(|count| count.set(count.get().saturating_add(1)));
1206}
1207
1208/// Reveal frames requested since the last call, and resets the counter.
1209///
1210/// `pub(crate)` so component tests can audit their own reveal demand — a
1211/// loaded trace proving it revealed nothing reads this, not a proxy.
1212#[cfg(test)]
1213pub(crate) fn take_reveal_frame_requests() -> usize {
1214    REVEAL_FRAME_REQUESTS.with(|count| count.replace(0))
1215}
1216
1217/// Fades and lifts an element into place once when it first mounts.
1218///
1219/// Keyed by `id`: a row that keeps its stable identity across renders plays
1220/// the reveal only on its first frames, never on every content update.
1221pub fn reveal<E>(element: E, id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> E
1222where
1223    E: Styled,
1224{
1225    apply_reveal(
1226        element,
1227        reveal_progress(id, Duration::ZERO, window, cx),
1228        travel(cx),
1229    )
1230}
1231
1232/// Like [`reveal`], but item `index` waits `index` stagger beats before it
1233/// starts, so a list of chips or rows ripples into place.
1234pub fn reveal_staggered<E>(
1235    element: E,
1236    id: impl Into<ElementId>,
1237    index: usize,
1238    window: &mut Window,
1239    cx: &mut App,
1240) -> E
1241where
1242    E: Styled,
1243{
1244    let delay = MotionTokens::read(cx).reveal().stagger_delay(index);
1245    apply_reveal(element, reveal_progress(id, delay, window, cx), travel(cx))
1246}
1247
1248/// Reveal travel from rest, in pixels.
1249///
1250/// A derived alias carrying no number of its own: the pixel-discipline gate
1251/// pins the displacement call site below by its exact expression, so the
1252/// distance stays a named local rather than an inline field access.
1253const REVEAL_RISE: f32 = EnterSpec::REVEAL.rise;
1254
1255fn apply_reveal<E: Styled>(element: E, progress: f32, travel: f32) -> E {
1256    element
1257        .opacity(progress)
1258        .top(px(REVEAL_RISE * (1.0 - progress) * travel))
1259}
1260
1261/// Slowly breathes an element's opacity — for indicators that mean "still
1262/// working" without a measurable progress value.
1263///
1264/// Breathes at the crate's default ambient tempo: this signature predates
1265/// [`MotionTokens`] and carries no `App`, so it has no way to see a replaced
1266/// policy. Components inside the crate route through the policy instead;
1267/// under the default tokens the two are identical.
1268///
1269/// This low-level compatibility helper returns GPUI's `AnimationElement`;
1270/// callers must unmount it while offscreen. The library's composed looping
1271/// components additionally suspend when clipped by a scroll container.
1272pub fn breathing<E>(element: E, id: impl Into<ElementId>) -> AnimationElement<E>
1273where
1274    E: IntoElement + Styled + 'static,
1275{
1276    breathing_with(MotionTokens::DEFAULT.breathing(), element, id)
1277}
1278
1279/// [`breathing`], at the tempo the given policy entry sets.
1280pub(crate) fn breathing_with<E>(
1281    spec: AmbientLoopSpec,
1282    element: E,
1283    id: impl Into<ElementId>,
1284) -> AnimationElement<E>
1285where
1286    E: IntoElement + Styled + 'static,
1287{
1288    element.with_animation(
1289        id,
1290        // Frame demand: ambient — active for as long as the caller keeps the
1291        // element mounted, which is the "still working, nothing to report"
1292        // state itself, so there is no settled frame to reach. Reduced
1293        // motion holds delta at 0, the middle of the opacity range.
1294        spec.looping().with_easing(pulsating_between(0.35, 1.0)),
1295        |element, alpha| element.opacity(alpha),
1296    )
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301    use super::*;
1302    use gpui::{Context, Render, TestAppContext, VisualTestContext, WindowHandle, px, size};
1303    use std::cell::Cell;
1304    use std::rc::Rc;
1305
1306    #[test]
1307    fn shimmer_band_travels_fully_across_and_rests_off_the_trailing_edge() {
1308        let default_duty = ProgressLoopSpec::SHIMMER.duty;
1309        assert!(default_duty > 0.0 && default_duty < 1.0);
1310        // Exercise the renderer's geometry with both the default timing and a
1311        // shorter sweep. Expectations describe position, not the easing formula.
1312        for duty in [default_duty, 0.4] {
1313            let start = shimmer_band_start(0.0, duty);
1314            let quarter = shimmer_band_start(duty * 0.25, duty);
1315            let middle = shimmer_band_start(duty * 0.5, duty);
1316            let three_quarters = shimmer_band_start(duty * 0.75, duty);
1317            let end = shimmer_band_start(duty, duty);
1318
1319            assert!(start + SHIMMER_BAND <= 0.0, "starts before the label");
1320            assert!(start < quarter && quarter < middle);
1321            assert!(middle < three_quarters && three_quarters < end);
1322            assert!(
1323                (middle + SHIMMER_BAND / 2.0 - 0.5).abs() < 1e-6,
1324                "the band crosses the label's center halfway through travel"
1325            );
1326            assert!(end >= 1.0, "clears the trailing edge");
1327            for delta in [duty + (1.0 - duty) * 0.5, 1.0] {
1328                assert_eq!(shimmer_band_start(delta, duty), end, "rests offscreen");
1329            }
1330        }
1331    }
1332
1333    #[test]
1334    fn stagger_is_bounded_so_long_lists_do_not_wait_forever() {
1335        let spec = MotionTokens::DEFAULT.reveal();
1336        let far = spec.stagger_delay(12);
1337        let capped = spec.stagger_delay(100);
1338        assert_eq!(far, capped);
1339        assert!(spec.stagger_delay(1) < far);
1340    }
1341
1342    #[test]
1343    fn default_policy_uses_the_documented_role_specs() {
1344        let tokens = MotionTokens::DEFAULT;
1345        assert_eq!(tokens.reveal(), EnterSpec::REVEAL);
1346        assert_eq!(tokens.shimmer(), ProgressLoopSpec::SHIMMER);
1347        assert_eq!(tokens.grid_sweep(), ProgressLoopSpec::GRID_SWEEP);
1348        assert_eq!(tokens.image_pulse(), ProgressLoopSpec::IMAGE_PULSE);
1349        assert_eq!(tokens.status_spinner(), ProgressLoopSpec::STATUS_SPINNER);
1350        assert_eq!(tokens.breathing(), AmbientLoopSpec::BREATHING);
1351        assert_eq!(tokens.orb_lattice(), AmbientLoopSpec::ORB_LATTICE);
1352        assert_eq!(tokens.standard(), EnterSpec::REVEAL.duration);
1353        assert_eq!(tokens.instant(), Duration::ZERO, "immediate is immediate");
1354        assert_eq!(MotionTokens::default(), tokens);
1355    }
1356
1357    #[test]
1358    fn default_roles_have_ordered_tempos_and_safe_springs() {
1359        let tokens = MotionTokens::DEFAULT;
1360        assert!(tokens.quick() > Duration::ZERO);
1361        assert!(tokens.quick() < tokens.standard());
1362        assert!(tokens.standard() < tokens.deliberate());
1363        for spring in [
1364            tokens.press_spring(),
1365            tokens.selection_spring(),
1366            tokens.disclosure_spring(),
1367            tokens.reflow_spring(),
1368        ] {
1369            assert!(spring.response() > Duration::ZERO);
1370            assert!(spring.damping() > 0.0);
1371        }
1372        // Destructive-adjacent roles must not overshoot.
1373        assert!(tokens.press_spring().damping() >= 1.0);
1374        assert!(tokens.disclosure_spring().damping() >= 1.0);
1375        assert!(tokens.reflow_spring().damping() >= 1.0);
1376    }
1377
1378    #[test]
1379    fn arrival_stagger_decelerates_within_its_caps() {
1380        let tokens = MotionTokens::DEFAULT;
1381        let delays: Vec<_> = (0..STAGGER_PARTICIPANTS)
1382            .map(|index| tokens.arrival_stagger(index, STAGGER_PARTICIPANTS))
1383            .collect();
1384        assert_eq!(delays[0], Duration::ZERO, "the first item leads at once");
1385        let gaps: Vec<_> = delays.windows(2).map(|pair| pair[1] - pair[0]).collect();
1386        for pair in gaps.windows(2) {
1387            assert!(
1388                pair[1] < pair[0],
1389                "the cascade must decelerate: gaps {gaps:?}"
1390            );
1391        }
1392        let last = *delays.last().expect("participants exist");
1393        assert!(last <= STAGGER_TOTAL_CAP);
1394        // Items past the participation bound arrive with the last beat, not
1395        // after it — a hundred-item load does not queue a hundred delays.
1396        assert_eq!(tokens.arrival_stagger(99, 100), last);
1397        // A single arrival has no cascade to join.
1398        assert_eq!(tokens.arrival_stagger(0, 1), Duration::ZERO);
1399    }
1400
1401    #[test]
1402    fn arrival_roster_retires_removed_identities() {
1403        let mut roster = ArrivalRoster::new();
1404        for id in 0..1_000 {
1405            roster.note(
1406                std::iter::once(ElementId::Integer(id)),
1407                true,
1408                &MotionTokens::DEFAULT,
1409                Instant::now(),
1410            );
1411            assert!(
1412                roster.seen.len() <= 1 && roster.delays.len() <= 1,
1413                "a one-item surface must not retain the history of removed items"
1414            );
1415        }
1416    }
1417
1418    #[test]
1419    fn arrival_roster_caps_overlapping_batches_and_retires_settled_clocks() {
1420        let mut roster = ArrivalRoster::new();
1421        let now = Instant::now();
1422        let tokens = MotionTokens::DEFAULT;
1423        roster.note(std::iter::empty(), true, &tokens, now);
1424        roster.note((0..1_000).map(ElementId::Integer), true, &tokens, now);
1425        assert_eq!(roster.delays.len(), STAGGER_PARTICIPANTS);
1426        roster.note((0..2_000).map(ElementId::Integer), true, &tokens, now);
1427        assert_eq!(roster.delays.len(), STAGGER_PARTICIPANTS);
1428        roster.note(
1429            (0..2_000).map(ElementId::Integer),
1430            true,
1431            &tokens,
1432            now + tokens.standard() + STAGGER_TOTAL_CAP,
1433        );
1434        assert!(roster.delays.is_empty(), "settled clocks must be retired");
1435    }
1436
1437    struct AcknowledgmentProbe {
1438        ordinal: u64,
1439        sample: Rc<Cell<f32>>,
1440    }
1441
1442    impl Render for AcknowledgmentProbe {
1443        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1444            self.sample.set(acknowledged_state(
1445                "ack-probe".into(),
1446                self.ordinal,
1447                window,
1448                cx,
1449            ));
1450            div()
1451        }
1452    }
1453
1454    #[gpui::test]
1455    fn acknowledgments_observe_every_change_without_replaying_snapshots(cx: &mut TestAppContext) {
1456        let sample = Rc::new(Cell::new(f32::NAN));
1457        let (probe, cx) = cx.add_window_view({
1458            let sample = sample.clone();
1459            move |_, _| AcknowledgmentProbe { ordinal: 0, sample }
1460        });
1461        draw(cx);
1462        assert_eq!(sample.get(), 1.0, "the first state is already presented");
1463
1464        for ordinal in [1, 0, 1] {
1465            probe.update(cx, |probe, cx| {
1466                probe.ordinal = ordinal;
1467                cx.notify();
1468            });
1469            draw(cx);
1470            assert_eq!(sample.get(), 0.0, "every controlled change acknowledges");
1471            cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
1472            assert_eq!(draw(cx), 0);
1473            assert_eq!(sample.get(), 1.0);
1474            assert_eq!(draw(cx), 0, "unchanged snapshots stay settled");
1475        }
1476
1477        // The OS flag resolves to the crossfade preference: the
1478        // acknowledgment still fades — comprehension-aiding opacity is
1479        // kept — and settles at the quick tempo.
1480        cx.update(|_, cx| cx.set_reduce_motion(true));
1481        probe.update(cx, |probe, cx| {
1482            probe.ordinal = 2;
1483            cx.notify();
1484        });
1485        assert!(draw(cx) > 0, "the crossfade acknowledgment runs");
1486        cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
1487        draw(cx);
1488        assert_eq!(sample.get(), 1.0);
1489        assert_eq!(draw(cx), 0, "and settles");
1490
1491        // The snap preference is the true zero: end state, no frames.
1492        cx.update(|_, cx| {
1493            MotionTokens::default()
1494                .with_preference(MotionPreference::Snap)
1495                .set(cx)
1496        });
1497        probe.update(cx, |probe, cx| {
1498            probe.ordinal = 3;
1499            cx.notify();
1500        });
1501        assert_eq!(draw(cx), 0, "snap acknowledges in one frame");
1502        assert_eq!(sample.get(), 1.0);
1503        cx.update(|_, cx| {
1504            MotionTokens::default().set(cx);
1505            cx.set_reduce_motion(false);
1506        });
1507        assert_eq!(
1508            draw(cx),
1509            0,
1510            "leaving reduced motion must not replay history"
1511        );
1512    }
1513
1514    #[gpui::test]
1515    fn install_respects_a_policy_the_application_chose_first(cx: &mut TestAppContext) {
1516        cx.update(|cx| {
1517            let chosen = Duration::from_millis(10);
1518            MotionTokens::default().with_standard(chosen).set(cx);
1519            install(cx);
1520            assert_eq!(
1521                MotionTokens::read(cx).standard(),
1522                chosen,
1523                "init must not overwrite an earlier application choice"
1524            );
1525        });
1526    }
1527
1528    #[gpui::test]
1529    fn install_provides_the_default_policy_when_none_was_chosen(cx: &mut TestAppContext) {
1530        cx.update(|cx| {
1531            assert_eq!(
1532                MotionTokens::read(cx),
1533                &MotionTokens::DEFAULT,
1534                "reads before install fall back to the same values"
1535            );
1536            install(cx);
1537            assert_eq!(MotionTokens::read(cx), &MotionTokens::DEFAULT);
1538        });
1539    }
1540
1541    #[derive(Debug, Clone, Copy)]
1542    enum LoopLifecycle {
1543        Active,
1544        Inactive,
1545        Complete,
1546        Offscreen,
1547        Dropped,
1548    }
1549
1550    struct LoopLifecycleProbe {
1551        lifecycle: LoopLifecycle,
1552    }
1553
1554    impl Render for LoopLifecycleProbe {
1555        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1556            let animated = || {
1557                div().with_animation(
1558                    "lifecycle-loop",
1559                    ProgressLoopSpec::SHIMMER.looping(),
1560                    |element, _| element,
1561                )
1562            };
1563            match self.lifecycle {
1564                LoopLifecycle::Active => div().child(animated()),
1565                // GPUI schedules an animation during layout, before clipping.
1566                // Offscreen suspension therefore means the virtualized row or
1567                // host does not build the animated element at all.
1568                LoopLifecycle::Inactive
1569                | LoopLifecycle::Complete
1570                | LoopLifecycle::Offscreen
1571                | LoopLifecycle::Dropped => div().child(div()),
1572            }
1573        }
1574    }
1575
1576    fn next_frame(window: &WindowHandle<LoopLifecycleProbe>, cx: &mut TestAppContext) -> usize {
1577        let callbacks = window
1578            .update(cx, |_, window, cx| window.simulate_next_frame(cx))
1579            .expect("the motion audit window should remain open");
1580        cx.run_until_parked();
1581        callbacks
1582    }
1583
1584    fn set_lifecycle(
1585        lifecycle: LoopLifecycle,
1586        window: &WindowHandle<LoopLifecycleProbe>,
1587        cx: &mut TestAppContext,
1588    ) {
1589        window
1590            .update(cx, |probe, _, cx| {
1591                probe.lifecycle = lifecycle;
1592                cx.notify();
1593            })
1594            .expect("the motion audit window should remain open");
1595        cx.run_until_parked();
1596        // A callback already queued by the previous active frame may fire once,
1597        // but the new static tree must not replace it.
1598        next_frame(window, cx);
1599    }
1600
1601    #[gpui::test]
1602    fn repeating_loops_request_frames_only_for_visible_active_work(cx: &mut TestAppContext) {
1603        let window = cx.open_window(size(px(120.), px(80.)), |_, _| LoopLifecycleProbe {
1604            lifecycle: LoopLifecycle::Active,
1605        });
1606        cx.run_until_parked();
1607        assert_eq!(next_frame(&window, cx), 1, "active work must keep moving");
1608
1609        for lifecycle in [
1610            LoopLifecycle::Inactive,
1611            LoopLifecycle::Complete,
1612            LoopLifecycle::Offscreen,
1613            LoopLifecycle::Dropped,
1614        ] {
1615            set_lifecycle(lifecycle, &window, cx);
1616            assert_eq!(
1617                next_frame(&window, cx),
1618                0,
1619                "{lifecycle:?} content must settle without idle frame demand"
1620            );
1621        }
1622
1623        set_lifecycle(LoopLifecycle::Active, &window, cx);
1624        assert_eq!(next_frame(&window, cx), 1);
1625    }
1626
1627    #[gpui::test]
1628    fn reduced_motion_holds_a_repeating_loop_on_its_static_frame(cx: &mut TestAppContext) {
1629        cx.update(|cx| cx.set_reduce_motion(true));
1630        let window = cx.open_window(size(px(120.), px(80.)), |_, _| LoopLifecycleProbe {
1631            lifecycle: LoopLifecycle::Active,
1632        });
1633        cx.run_until_parked();
1634        assert_eq!(next_frame(&window, cx), 0);
1635    }
1636
1637    /// Runs one reveal per draw and remembers what it returned.
1638    struct RevealProbe {
1639        delay: Duration,
1640        progress: Rc<Cell<f32>>,
1641    }
1642
1643    impl Render for RevealProbe {
1644        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1645            self.progress
1646                .set(reveal_progress("reveal-probe", self.delay, window, cx));
1647            div()
1648        }
1649    }
1650
1651    fn reveal_probe(
1652        reduce_motion: bool,
1653        cx: &mut TestAppContext,
1654    ) -> (Rc<Cell<f32>>, &mut VisualTestContext) {
1655        let progress = Rc::new(Cell::new(f32::NAN));
1656        let (_, cx) = cx.add_window_view({
1657            let progress = progress.clone();
1658            move |_, _| RevealProbe {
1659                delay: Duration::ZERO,
1660                progress,
1661            }
1662        });
1663        cx.update(|_, cx| cx.set_reduce_motion(reduce_motion));
1664        // Opening the window may already have drawn; the audit starts here.
1665        take_reveal_frame_requests();
1666        (progress, cx)
1667    }
1668
1669    fn draw(cx: &mut VisualTestContext) -> usize {
1670        cx.update(|window, cx| window.draw(cx).clear(cx));
1671        take_reveal_frame_requests()
1672    }
1673
1674    #[gpui::test]
1675    fn an_active_reveal_requests_a_frame_per_draw_until_it_settles(cx: &mut TestAppContext) {
1676        let (progress, cx) = reveal_probe(false, cx);
1677
1678        assert_eq!(draw(cx), 1, "a reveal at zero progress must keep drawing");
1679        assert_eq!(progress.get(), 0.0);
1680
1681        cx.executor().advance_clock(EnterSpec::REVEAL.duration / 2);
1682        assert_eq!(draw(cx), 1, "a half-played reveal must keep drawing");
1683        assert!(
1684            (0.0..1.0).contains(&progress.get()),
1685            "progress={}",
1686            progress.get()
1687        );
1688    }
1689
1690    #[gpui::test]
1691    fn a_settled_reveal_requests_no_further_frames(cx: &mut TestAppContext) {
1692        let (progress, cx) = reveal_probe(false, cx);
1693        draw(cx);
1694
1695        cx.executor().advance_clock(EnterSpec::REVEAL.duration);
1696        assert_eq!(draw(cx), 0, "the settling draw must be the last one");
1697        assert_eq!(progress.get(), 1.0);
1698
1699        // Redraws for unrelated reasons must not restart the demand.
1700        assert_eq!(draw(cx), 0);
1701        assert_eq!(draw(cx), 0);
1702        assert_eq!(progress.get(), 1.0);
1703    }
1704
1705    #[gpui::test]
1706    fn reduced_motion_crossfades_at_the_quick_tempo(cx: &mut TestAppContext) {
1707        // The OS flag no longer hard-snaps: it resolves to the crossfade
1708        // preference, which keeps the opacity fade — clamped to the quick
1709        // tempo, travel zeroed by `travel` — and settles quiet.
1710        let (progress, cx) = reveal_probe(true, cx);
1711
1712        assert!(draw(cx) > 0, "the crossfade fade is live");
1713        assert!(progress.get() < 1.0);
1714        cx.update(|_, cx| assert_eq!(travel(cx), 0.0, "travel is zero under the flag"));
1715
1716        cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
1717        draw(cx);
1718        assert_eq!(progress.get(), 1.0);
1719        assert_eq!(draw(cx), 0, "a settled crossfade asks for nothing");
1720    }
1721
1722    #[gpui::test]
1723    fn the_snap_preference_returns_the_end_state_without_frames(cx: &mut TestAppContext) {
1724        let (progress, cx) = reveal_probe(false, cx);
1725        cx.update(|_, cx| {
1726            MotionTokens::default()
1727                .with_preference(MotionPreference::Snap)
1728                .set(cx)
1729        });
1730
1731        assert_eq!(draw(cx), 0, "snap must not animate");
1732        assert_eq!(progress.get(), 1.0);
1733
1734        cx.executor().advance_clock(EnterSpec::REVEAL.duration);
1735        assert_eq!(draw(cx), 0);
1736        assert_eq!(progress.get(), 1.0);
1737    }
1738
1739    #[gpui::test]
1740    fn a_replaced_policy_drives_a_running_reveal(cx: &mut TestAppContext) {
1741        let quickened = Duration::from_millis(10);
1742        let (progress, cx) = reveal_probe(false, cx);
1743        cx.update(|_, cx| MotionTokens::default().with_standard(quickened).set(cx));
1744
1745        assert_eq!(draw(cx), 1, "the reveal is live under the new policy");
1746        cx.executor().advance_clock(quickened);
1747        draw(cx);
1748        assert_eq!(
1749            progress.get(),
1750            1.0,
1751            "ten milliseconds settles a ten-millisecond policy"
1752        );
1753        assert_eq!(draw(cx), 0, "and a settled reveal stays settled");
1754    }
1755
1756    #[gpui::test]
1757    fn the_default_policy_is_still_travelling_at_that_moment(cx: &mut TestAppContext) {
1758        // The differential half of the test above: the same clock advance
1759        // under the default policy must not have settled, or the assertion
1760        // there would pass for reasons other than the replacement working.
1761        let (progress, cx) = reveal_probe(false, cx);
1762        draw(cx);
1763        cx.executor().advance_clock(Duration::from_millis(10));
1764        draw(cx);
1765        assert!(
1766            progress.get() < 1.0,
1767            "progress={} — the default tempo settled implausibly fast",
1768            progress.get()
1769        );
1770    }
1771
1772    #[gpui::test]
1773    fn no_policy_value_can_override_reduced_motion(cx: &mut TestAppContext) {
1774        // The OS signal floors the effective preference at crossfade: a
1775        // ten-second policy still settles at the quick tempo with zero
1776        // travel, and a policy asking for full motion cannot win it back.
1777        let (progress, cx) = reveal_probe(true, cx);
1778        cx.update(|_, cx| {
1779            MotionTokens::default()
1780                .with_standard(Duration::from_secs(10))
1781                .with_preference(MotionPreference::Full)
1782                .set(cx);
1783            assert_eq!(
1784                MotionTokens::effective_preference(cx),
1785                MotionPreference::Crossfade,
1786                "full cannot outrank the OS signal"
1787            );
1788            assert_eq!(travel(cx), 0.0);
1789        });
1790
1791        cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
1792        draw(cx);
1793        assert_eq!(progress.get(), 1.0, "settled at quick, not at ten seconds");
1794
1795        // The policy may restrict further than the signal, never less.
1796        cx.update(|_, cx| {
1797            MotionTokens::default()
1798                .with_preference(MotionPreference::Snap)
1799                .set(cx);
1800            assert_eq!(
1801                MotionTokens::effective_preference(cx),
1802                MotionPreference::Snap
1803            );
1804        });
1805    }
1806}