Skip to main content

motion/
lib.rs

1//! Animation kit — a named motion catalog as reusable helpers over gpui
2//! [`Animation`]/[`AnimationExt`].
3//!
4//! Catalog (docs/research/feature-inventory.md §1.12):
5//! - `fade-in`   0.5s  cubic-bezier(0.16,1,0.3,1), translateY 4→0 (entrances)
6//! - `fade-quick` 0.15s
7//! - `menu-in`   0.14s scale 0.96 + translateY −2 (popovers)
8//! - `dialog-in` 0.18s scale 0.96→1
9//! - `splash-out` 0.5s opacity + translateY −6, 0.15s delay
10//! - `pulse` 2.4s staggered cell opacity 0.08→1, scale 0.9→1 (loaders)
11//! - `gradient-spin-pulse` 750ms per-cell phase wave (working indicator)
12//! - 200ms ease-out width/height transitions (sidebar/panes)
13//!
14//! Custom easing is a closure over gpui's `Fn(f32) -> f32` easing shape; CSS
15//! `cubic-bezier()` is evaluated exactly by [`CubicBezier`].
16//!
17//! Reduced motion: gpui's `App::reduce_motion` flag is honored *automatically* by
18//! every `with_animation` element — oneshot animations snap to their end state,
19//! repeating ones to their start state, and no frames are scheduled.
20//! [`AppExt`] carries that switch and the rest of the app-level settings; pure
21//! helpers take the flag explicitly where they run outside elements.
22//!
23//! translateY is implemented as a relative-position `top` inset: taffy applies
24//! relative insets after layout, so — like a CSS transform — siblings never move.
25//! gpui has no scale transform for `div`s at the pinned rev (only `svg`
26//! transformations), so `menu-in`/`dialog-in` approximate their scale component
27//! with fade + translate.
28
29use std::{
30    cell::RefCell,
31    collections::HashMap,
32    sync::atomic::{AtomicU32, Ordering},
33    time::Duration,
34};
35
36use web_time::Instant;
37
38use gpui::{
39    Animation, AnimationElement, App, Context, ElementId, EntityId, Global, Hsla, IntoElement,
40    Rgba, SharedString, Styled, Window, px,
41};
42
43/// What the catalog's one-shot entrances are built on. Repeats belong on
44/// [`pulse_delta`], which leases the view instead of pinning the window.
45pub use gpui::AnimationExt;
46
47mod app;
48pub mod phase;
49
50pub use app::AppExt;
51
52// ---------------------------------------------------------------------------
53// The clock — one leased drive for everything that repeats
54// ---------------------------------------------------------------------------
55
56/// Redraw rate for the pulse/spinner loaders.
57///
58/// 2026-08, M-series laptop: one spinner drawn at 120Hz cost 36% of a core —
59/// the window redraw, with the animation math itself at 0.3%. 30fps is
60/// visually equivalent for these chunky cell waves at a quarter of the draws,
61/// and a window with no spinner mounted schedules nothing at all.
62const PULSE_FPS: f32 = 30.0;
63
64/// How long a view stays on the tick list after its last spinner paint. One
65/// lease outlives a few missed frames; an unmounted spinner stops renewing and
66/// the view drops off, letting the clock park.
67const PULSE_LEASE: Duration = Duration::from_millis(300);
68
69/// Redraw rate for hover fades. [`HOVER_FADE`] is 150ms, so this paints it in
70/// nine steps — the drive it replaces ran at the display's rate, whatever that
71/// was.
72const HOVER_FPS: f32 = 60.0;
73
74/// Floor on how long the clock sleeps between wake-ups, so a lease asking for
75/// an absurd rate cannot turn the loop into a spin.
76const MIN_SLEEP: Duration = Duration::from_millis(1);
77
78/// One view's claim on the clock.
79struct Lease {
80    /// The fastest rate anything on this view has claimed.
81    period: Duration,
82    /// When this view is next owed a redraw.
83    due: Instant,
84    /// A notify is out and the render it provoked has not renewed this lease
85    /// yet. Read by [`Painter::woken`] — never by the schedule, because a
86    /// claim taken from an *event* is renewed by no render at all: a hover
87    /// fade would paint one frame and freeze there.
88    in_flight: bool,
89    /// When the claim lapses if nothing renews it.
90    until: Instant,
91}
92
93#[derive(Default)]
94struct PulseClock {
95    /// Set on first use. Everything here reads the executor's clock rather than
96    /// `Instant::now()` — one time source for scheduling and for phase, and the
97    /// only way a test can advance a second of animation without waiting one.
98    epoch: Option<Instant>,
99    leases: HashMap<EntityId, Lease>,
100    running: bool,
101}
102
103impl PulseClock {
104    /// When the loop should next wake: the earliest thing owed to anyone, or
105    /// the earliest lapse, whichever comes first.
106    ///
107    /// Every lease answers with a real time. A lease that fell back to its
108    /// *lapse* while waiting on a render is what held every drive in the
109    /// library to one frame per lease rather than one per period — a spinner
110    /// asking for 30fps drew about 5.
111    fn next_wake(&self) -> Option<Instant> {
112        self.leases
113            .values()
114            .map(|lease| lease.due.min(lease.until))
115            .min()
116    }
117}
118
119impl Global for PulseClock {}
120
121/// A component's line back to the view that paints it.
122///
123/// Component state that outlives a render — a hover fade, a gesture — has to
124/// name its view, because event-dispatch context cannot resolve one and the
125/// only alternative left is refreshing the whole window. Holding a `Painter`
126/// is what makes the two sanctioned frame requests reachable, and it is the
127/// one surface to review when asking who in the library can ask for a frame.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129pub struct Painter(EntityId);
130
131impl Painter {
132    /// The view this render belongs to. Take it once, where the state is
133    /// built, and keep it for as long as the state lives.
134    pub fn of<T: 'static>(cx: &Context<T>) -> Self {
135        Self(cx.entity_id())
136    }
137
138    /// Redraw this view once.
139    pub fn notify(self, cx: &mut App) {
140        cx.notify(self.0);
141    }
142
143    /// Claim `fps` redraws a second, lapsing `until` from now unless something
144    /// renews it. One timer serves the whole app: it wakes only when a view is
145    /// owed a frame, notifies that view alone, and parks when the last claim
146    /// lapses.
147    ///
148    /// This is the drive for any repeating animation, yours included. gpui's
149    /// `with_animation(…).repeat()` asks the *window* for a frame at the
150    /// display's rate for as long as it stays mounted, and one such element is
151    /// enough to hold the whole window there.
152    ///
153    /// Renew it from `render` — that is what makes a claim self-cancelling,
154    /// since an element that unmounts stops renewing and drops off. The rate is
155    /// a per-view minimum of everything claiming it, so a 60fps claim never
156    /// drags a 30fps one up with it.
157    pub fn lease(self, fps: f32, until: Duration, cx: &mut App) {
158        lease(self.0, fps, until, cx);
159    }
160
161    /// Whether the clock is what asked for the render running now, rather than
162    /// the app. True only in the render a tick provoked: the clock clears what
163    /// this view is owed when it notifies, and the render's own [`lease`] is
164    /// what puts it back. Read it before renewing.
165    pub fn woken(self, cx: &App) -> bool {
166        cx.try_global::<PulseClock>()
167            .and_then(|clock| clock.leases.get(&self.0))
168            .is_some_and(|lease| lease.in_flight)
169    }
170}
171
172impl From<Painter> for EntityId {
173    fn from(painter: Painter) -> Self {
174        painter.0
175    }
176}
177
178impl From<EntityId> for Painter {
179    fn from(view: EntityId) -> Self {
180        Self(view)
181    }
182}
183
184fn lease(view: EntityId, fps: f32, until: Duration, cx: &mut App) {
185    // Nobody is looking. Refusing the claim is enough to stop the clock: the
186    // ones already held lapse, the loop runs out of work and parks, and the
187    // refresh gpui does on activation brings the renders that claim again.
188    if cx.pause_when_inactive() && cx.active_window().is_none() {
189        return;
190    }
191    let now = cx.background_executor().now();
192    let period = Duration::from_secs_f32(1.0 / fps.clamp(1.0, 240.0));
193    let clock = cx.default_global::<PulseClock>();
194    clock
195        .leases
196        .entry(view)
197        .and_modify(|lease| {
198            lease.period = lease.period.min(period);
199            // The tick already booked the next slot; this render only pulls it
200            // earlier if the claim is faster. Re-dating it from *now* would add
201            // the frame's own cost to every period — 30fps drew 20.
202            lease.due = lease.due.min(now + period);
203            lease.in_flight = false;
204            lease.until = lease.until.max(now + until);
205        })
206        .or_insert(Lease {
207            period,
208            due: now + period,
209            in_flight: false,
210            until: now + until,
211        });
212    if clock.running {
213        return;
214    }
215    clock.running = true;
216    cx.spawn(async move |cx| {
217        loop {
218            let sleep = cx.update(|cx| {
219                let now = cx.background_executor().now();
220                let clock = cx.default_global::<PulseClock>();
221                clock
222                    .next_wake()
223                    .map(|wake| wake.saturating_duration_since(now).max(MIN_SLEEP))
224            });
225            let Some(sleep) = sleep else { break };
226            cx.background_executor().timer(sleep).await;
227            let parked = cx.update(|cx| {
228                let now = cx.background_executor().now();
229                // The clock is the fade store's tick too: it is what advances
230                // the frame counter that evicts fades whose elements went away.
231                tick_hover_fades();
232                let clock = cx.default_global::<PulseClock>();
233                clock.leases.retain(|_, lease| lease.until > now);
234                if clock.leases.is_empty() {
235                    clock.running = false;
236                    return true;
237                }
238                // Owed a frame now. The slot moves on whatever happens next,
239                // so the cadence is the period and not the period plus however
240                // long the frame took.
241                let mut owed = Vec::new();
242                for (view, lease) in clock.leases.iter_mut() {
243                    if lease.due > now {
244                        continue;
245                    }
246                    lease.due += lease.period;
247                    // Frames are costing more than the rate asked for; the next
248                    // slot is now rather than a run of slots already missed.
249                    if lease.due <= now {
250                        lease.due = now + lease.period;
251                    }
252                    lease.in_flight = true;
253                    owed.push(*view);
254                }
255                for view in owed {
256                    cx.notify(view);
257                }
258                false
259            });
260            if parked {
261                break;
262            }
263        }
264    })
265    .detach();
266}
267
268/// Current phase `[0,1)` of a repeating spec, plus a [`lease`] that keeps the
269/// calling view re-rendering at [`PULSE_FPS`] while its spinner stays mounted.
270/// All cells across all views share one epoch, so multi-instance loaders stay
271/// phase-locked. Reduced motion returns a static 0 and schedules nothing.
272pub fn pulse_delta(spec: &MotionSpec, painter: Painter, cx: &mut App) -> f32 {
273    if cx.reduce_motion() {
274        return 0.0;
275    }
276    painter.lease(PULSE_FPS, PULSE_LEASE, cx);
277    let now = cx.background_executor().now();
278    let clock = cx.default_global::<PulseClock>();
279    let epoch = *clock.epoch.get_or_insert(now);
280    let period = spec.total().as_secs_f32();
281    ((now - epoch).as_secs_f32() / period).fract()
282}
283
284// ---------------------------------------------------------------------------
285// Cubic bezier
286// ---------------------------------------------------------------------------
287
288/// A CSS `cubic-bezier(x1, y1, x2, y2)` timing function (endpoints fixed at
289/// (0,0) and (1,1)). Evaluation solves x(t) = input by Newton iteration with a
290/// bisection fallback — the standard UnitBezier approach.
291#[derive(Debug, Clone, Copy, PartialEq)]
292pub struct CubicBezier {
293    pub x1: f32,
294    pub y1: f32,
295    pub x2: f32,
296    pub y2: f32,
297}
298
299impl CubicBezier {
300    pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
301        Self { x1, y1, x2, y2 }
302    }
303
304    fn coefficients(a: f32, b: f32) -> (f32, f32, f32) {
305        let c = 3.0 * a;
306        let bb = 3.0 * (b - a) - c;
307        let aa = 1.0 - c - bb;
308        (aa, bb, c)
309    }
310
311    fn sample_x(&self, t: f32) -> f32 {
312        let (a, b, c) = Self::coefficients(self.x1, self.x2);
313        ((a * t + b) * t + c) * t
314    }
315
316    fn sample_y(&self, t: f32) -> f32 {
317        let (a, b, c) = Self::coefficients(self.y1, self.y2);
318        ((a * t + b) * t + c) * t
319    }
320
321    fn sample_x_derivative(&self, t: f32) -> f32 {
322        let (a, b, c) = Self::coefficients(self.x1, self.x2);
323        (3.0 * a * t + 2.0 * b) * t + c
324    }
325
326    /// Curve parameter `t` for a given progress `x` (both 0..1).
327    fn solve_t_for_x(&self, x: f32) -> f32 {
328        // Newton–Raphson.
329        let mut t = x;
330        for _ in 0..8 {
331            let err = self.sample_x(t) - x;
332            if err.abs() < 1e-6 {
333                return t;
334            }
335            let d = self.sample_x_derivative(t);
336            if d.abs() < 1e-6 {
337                break;
338            }
339            t -= err / d;
340        }
341        // Bisection fallback (x(t) is monotonic for valid CSS beziers).
342        let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
343        for _ in 0..32 {
344            let mid = (lo + hi) / 2.0;
345            if self.sample_x(mid) < x {
346                lo = mid
347            } else {
348                hi = mid
349            }
350        }
351        (lo + hi) / 2.0
352    }
353
354    /// Eased output for input progress `x ∈ [0,1]` (clamped). The output is
355    /// clamped to `[0,1]` too; gpui's animation element aborts on a delta
356    /// outside it.
357    pub fn eval(&self, x: f32) -> f32 {
358        if x <= 0.0 {
359            return 0.0;
360        }
361        if x >= 1.0 {
362            return 1.0;
363        }
364        // f32 rounding can push sample_y a hair past 1.0 (observed 1.000000119
365        // near the end of menu animations); gpui's animation element asserts
366        // `delta ∈ [0,1]` and aborts, so clamp the output hard.
367        self.sample_y(self.solve_t_for_x(x)).clamp(0.0, 1.0)
368    }
369
370    /// This curve as a gpui easing closure.
371    pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
372        move |x| self.eval(x)
373    }
374}
375
376/// The signature entrance curve — CSS `cubic-bezier(0.16, 1, 0.3, 1)`.
377pub const EASE_OUT_EXPO: CubicBezier = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
378/// CSS `ease-out` — width/height transitions.
379pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
380/// CSS `ease` — quick fades, menu/dialog pops.
381pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
382/// Sidebar resort glide — CSS `cubic-bezier(0.22, 1, 0.36, 1)` (used from M3b).
383pub const EASE_RESORT: CubicBezier = CubicBezier::new(0.22, 1.0, 0.36, 1.0);
384/// CSS `ease-in-out` — the transcript scroll glide (browser smooth-scroll
385/// shape: gentle start, cruise, gentle landing).
386pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
387
388// ---------------------------------------------------------------------------
389// Motion specs (the catalog)
390// ---------------------------------------------------------------------------
391
392/// One catalog entry: duration + optional delay + curve. The delay is folded into
393/// the gpui animation timeline (gpui `Animation` has no native delay): the
394/// animation runs for `delay + duration` and [`progress`](Self::progress) holds 0
395/// until the delay has elapsed.
396#[derive(Debug, Clone, Copy, PartialEq)]
397pub struct MotionSpec {
398    pub duration_ms: u64,
399    pub delay_ms: u64,
400    pub curve: CubicBezier,
401}
402
403impl MotionSpec {
404    pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
405        Self {
406            duration_ms,
407            delay_ms: 0,
408            curve,
409        }
410    }
411
412    pub const fn with_delay(mut self, delay_ms: u64) -> Self {
413        self.delay_ms = delay_ms;
414        self
415    }
416
417    /// Wall-clock span of the whole timeline (delay + duration).
418    pub fn total(&self) -> Duration {
419        Duration::from_millis(self.delay_ms + self.duration_ms)
420    }
421
422    /// Eased progress (0..1) for a raw timeline delta (0..1 across [`total`](Self::total)).
423    /// Pure — unit-testable without a window.
424    pub fn progress(&self, raw_delta: f32) -> f32 {
425        let total = (self.delay_ms + self.duration_ms) as f32;
426        if total <= 0.0 || self.duration_ms == 0 {
427            return 1.0;
428        }
429        let t =
430            (raw_delta.clamp(0.0, 1.0) * total - self.delay_ms as f32) / self.duration_ms as f32;
431        self.curve.eval(t.clamp(0.0, 1.0))
432    }
433
434    /// A oneshot gpui [`Animation`] for this spec (delay folded in).
435    /// Wall-clock span honors [`speed_scale`] (measurement knob).
436    pub fn animation(&self) -> Animation {
437        let spec = *self;
438        Animation::new(spec.total().mul_f32(speed_scale())).with_easing(move |d| spec.progress(d))
439    }
440}
441
442/// Entrances: 0.5s expo-out fade + 4px rise.
443pub const FADE_IN: MotionSpec = MotionSpec::new(500, EASE_OUT_EXPO);
444/// Quick fade: 0.15s.
445pub const FADE_QUICK: MotionSpec = MotionSpec::new(150, EASE);
446/// Popover-in: 0.14s (scale 0.96 approximated, translateY −2).
447pub const MENU_IN: MotionSpec = MotionSpec::new(140, EASE);
448/// Popover-out: 0.1s — quicker than the entrance (exits should get out of the
449/// way; matches the Radix convention of a shorter close than open).
450pub const MENU_OUT: MotionSpec = MotionSpec::new(100, EASE);
451/// Dialog-in: 0.18s (scale 0.96→1 approximated).
452pub const DIALOG_IN: MotionSpec = MotionSpec::new(180, EASE);
453/// Boot splash exit: 0.5s fade + 6px lift after a 0.15s hold.
454pub const SPLASH_OUT: MotionSpec = MotionSpec::new(500, EASE).with_delay(150);
455/// Sidebar / pane width+height transitions: 200ms ease-out.
456pub const RESIZE: MotionSpec = MotionSpec::new(200, EASE_OUT);
457/// Terminal tab drag-reorder sliding transforms: 150ms (§1.10).
458pub const TAB_SLIDE: MotionSpec = MotionSpec::new(150, EASE_OUT);
459/// Diff-pane per-file collapse: 180ms height (§1.11).
460pub const COLLAPSE: MotionSpec = MotionSpec::new(180, EASE_OUT);
461/// Canvas nodes gliding to where a layout put them — [`RESIZE`]'s 200ms
462/// ease-out, for a move the reader caused rather than one they are dragging.
463pub const LAYOUT: MotionSpec = MotionSpec::new(200, EASE_OUT);
464/// Diff-pane chevron rotate: 200ms (§1.11; approximated as a crossfade — gpui
465/// divs have no rotation transform at the pinned rev, same caveat as scale).
466pub const CHEVRON: MotionSpec = MotionSpec::new(200, EASE);
467/// Rail-tick / scroll-to-row glide: 500ms ease-in-out over the whole distance
468/// (Electron parity — the original rail rode the browser's native smooth
469/// scroll, a fixed-duration gentle ease, never percent-of-remaining).
470pub const SCROLL_GLIDE: MotionSpec = MotionSpec::new(500, EASE_IN_OUT);
471/// Tailwind's default transition curve — CSS `cubic-bezier(0.4, 0, 0.2, 1)`
472/// (`transition-colors` et al. carry it unless overridden).
473pub const EASE_TAILWIND: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0);
474/// CSS `transition-colors` default: 150ms over [`EASE_TAILWIND`] — the temporal
475/// blend every interactive hover wash rides in the original.
476pub const HOVER_FADE: MotionSpec = MotionSpec::new(150, EASE_TAILWIND);
477/// Pulse loader period: 2.4s.
478pub const PULSE: MotionSpec = MotionSpec::new(2400, EASE);
479/// Gradient matrix spinner wave period: 750ms.
480pub const GRADIENT_SPIN: MotionSpec = MotionSpec::new(750, EASE);
481/// Orb cluster breath: 2s, and `EASE_IN_OUT` because a breath has no edges —
482/// the two spinners tick, this one swells.
483pub const ORB: MotionSpec = MotionSpec::new(phase::ORB_MS, EASE_IN_OUT);
484
485// ---------------------------------------------------------------------------
486// Element helpers (paint-layer entrances/exits)
487// ---------------------------------------------------------------------------
488
489/// Standard entrance: opacity 0→1 + translateY 4→0 over [`FADE_IN`].
490pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
491where
492    E: Styled + IntoElement + 'static,
493{
494    element.with_animation(id, FADE_IN.animation(), |el, t| {
495        el.relative().opacity(t).top(px(4.0 * (1.0 - t)))
496    })
497}
498
499/// Quick opacity-only fade over [`FADE_QUICK`].
500pub fn fade_quick<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
501where
502    E: Styled + IntoElement + 'static,
503{
504    element.with_animation(id, FADE_QUICK.animation(), |el, t| el.opacity(t))
505}
506
507/// Popover entrance: fade + translateY −2→0 over [`MENU_IN`].
508/// (the original also scales 0.96→1; divs have no scale transform in gpui — approximated.)
509pub fn menu_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
510where
511    E: Styled + IntoElement + 'static,
512{
513    element.with_animation(id, MENU_IN.animation(), |el, t| {
514        el.relative()
515            .opacity(0.3 + 0.7 * t)
516            .top(px(-2.0 * (1.0 - t)))
517    })
518}
519
520/// Popover exit: the reverse of [`menu_in`] — fade to 0 + translateY 0→−2 over
521/// [`MENU_OUT`]. Unlike the entrances, the eased progress `t` comes from the
522/// caller (computed off `bezel::popover::Popup`'s closing instant at render
523/// time): `with_animation`'s element-id-keyed clock replays from 0 on remount
524/// (the hover-blend comment's warning), and a replay mid-exit is a full-opacity
525/// flash. The wall-clock progress is monotonic by construction; the animation
526/// wrapper here only pumps frames for the exit's span, its own delta unused.
527pub fn menu_out<E>(id: impl Into<ElementId>, t: f32, element: E) -> AnimationElement<E>
528where
529    E: Styled + IntoElement + 'static,
530{
531    element.with_animation(id, MENU_OUT.animation(), move |el, _| {
532        el.relative().opacity(1.0 - t).top(px(-2.0 * t))
533    })
534}
535
536/// Dialog entrance over [`DIALOG_IN`] (scale approximated with fade + 2px rise).
537pub fn dialog_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
538where
539    E: Styled + IntoElement + 'static,
540{
541    element.with_animation(id, DIALOG_IN.animation(), |el, t| {
542        el.relative().opacity(t).top(px(2.0 * (1.0 - t)))
543    })
544}
545
546/// Boot-splash exit: hold 150ms, then fade out + lift 6px over 500ms.
547pub fn splash_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
548where
549    E: Styled + IntoElement + 'static,
550{
551    element.with_animation(id, SPLASH_OUT.animation(), |el, t| {
552        el.opacity(1.0 - t).top(px(-6.0 * t))
553    })
554}
555
556// ---------------------------------------------------------------------------
557// Loader math (pure; rendered by bezel::loaders)
558// ---------------------------------------------------------------------------
559
560// The loader constants and math live in `crate::phase` (pure phase
561// functions); this crate animates them with gpui.
562pub use crate::phase::{
563    ORB_BLOOM_RINGS, ORB_RING_DOT, ORB_RING_DOTS, ORB_RING_RADIUS, ORB_SEATS, ORBS,
564    PULSE_MIN_OPACITY, PULSE_MIN_SCALE, PULSE_STAGGER, gspin_opacity, orb_bloom_opacity,
565    orb_bloom_radius, orb_converge_radius, orb_drift, orb_glow, orb_opacity, orb_ring_seat,
566    orb_size, pulse_opacity, pulse_scale, pulse_wave, staggered_phase,
567};
568
569/// Gradient-matrix spinner wave: intensity (0..1) of cell `wave_index` out of
570/// `wave_count` diagonals, at raw delta `raw_delta` of the 750ms period. The wave
571/// front travels across diagonals once per period.
572pub fn matrix_wave(raw_delta: f32, wave_index: usize, wave_count: usize) -> f32 {
573    let count = wave_count.max(1) as f32;
574    pulse_wave(staggered_phase(raw_delta, wave_index, 1.0 / count))
575}
576
577/// Linear interpolation (layout tweens).
578pub fn lerp(from: f32, to: f32, t: f32) -> f32 {
579    from + (to - from) * t
580}
581
582// ---------------------------------------------------------------------------
583// Hover color fades (CSS `transition-colors` parity)
584// ---------------------------------------------------------------------------
585//
586// gpui `.hover()` styles snap by construction — the style applies the frame
587// the pointer enters. The original CSS puts Tailwind `transition-colors`
588// (150ms, cubic-bezier(0.4, 0, 0.2, 1)) on every interactive wash, so hover
589// states FADE. This is the manual-drive tween for that (the shell `WidthTween`
590// pattern — never `with_animation`, whose element-id-keyed clock replays on
591// remount): a per-element-key hover progress, advanced from wall time on each
592// evaluation, with the render tail requesting frames while any fade is
593// mid-flight.
594//
595// The store is a main-thread `thread_local` rather than a gpui Global so the
596// many free-function element builders (window-control buttons, popover menu
597// rows, markdown code blocks) can blend colors without threading `cx` through
598// every signature. All access happens on the UI thread (element builders,
599// mouse listeners, the render tail).
600//
601// Staleness: an element that unmounts mid-hover never gets its leave event, so
602// entries are stamped with a frame counter on every read and pruned by the
603// clock's tick when a full tick passes without a read — a reopened menu never
604// inherits a dead entry's wash.
605
606/// One element's hover fade: progress runs `origin → target` over
607/// [`HOVER_FADE`], re-anchored at `origin` whenever the pointer flips
608/// direction mid-flight so the blend is continuous.
609#[derive(Debug, Clone, Copy)]
610pub struct FadeEntry {
611    origin: f32,
612    target: f32,
613    started: Instant,
614    /// Frame counter at the last read (liveness stamp — see module notes).
615    seen: u64,
616}
617
618impl FadeEntry {
619    fn value(&self, now: Instant, duration: Duration) -> f32 {
620        let elapsed = now.saturating_duration_since(self.started);
621        if duration.is_zero() || elapsed >= duration {
622            return self.target;
623        }
624        let raw = elapsed.as_secs_f32() / duration.as_secs_f32();
625        lerp(self.origin, self.target, HOVER_FADE.curve.eval(raw))
626    }
627
628    fn settled(&self, now: Instant, duration: Duration) -> bool {
629        self.origin == self.target || now.saturating_duration_since(self.started) >= duration
630    }
631}
632
633/// Which fade: the view that paints it, and which element inside that view.
634///
635/// The view is half the identity because the store is one map for the whole
636/// app. Keyed on the string alone, two views using `"row-3"` trade each other's
637/// wash — which is why the ghost button used to ask callers for a key unique
638/// across the entire program.
639#[derive(Debug, Clone, PartialEq, Eq, Hash)]
640pub struct Fade {
641    pub painter: Painter,
642    pub key: SharedString,
643}
644
645impl Fade {
646    pub fn new(painter: Painter, key: impl Into<SharedString>) -> Self {
647        Self {
648            painter,
649            key: key.into(),
650        }
651    }
652}
653
654/// Per-fade progress store. Pure core (explicit `now`) — unit-testable; the
655/// thread-local wrappers below feed it the clock.
656#[derive(Default)]
657pub struct HoverFades {
658    pub entries: HashMap<Fade, FadeEntry>,
659    frame: u64,
660}
661
662impl HoverFades {
663    pub fn duration() -> Duration {
664        HOVER_FADE.total().mul_f32(speed_scale())
665    }
666
667    /// Pointer entered (`hovered`) or left the element behind `fade`. Reduced
668    /// motion snaps straight to the endpoint.
669    pub fn set_at(&mut self, fade: &Fade, hovered: bool, reduced: bool, now: Instant) {
670        let target = if hovered { 1.0 } else { 0.0 };
671        let duration = Self::duration();
672        let current = self
673            .entries
674            .get(fade)
675            .map(|e| e.value(now, duration))
676            .unwrap_or(0.0);
677        if target == 0.0 && !self.entries.contains_key(fade) {
678            return; // never-hovered element reporting a leave — nothing to do
679        }
680        let origin = if reduced { target } else { current };
681        let seen = self.frame;
682        self.entries.insert(
683            fade.clone(),
684            FadeEntry {
685                origin,
686                target,
687                started: now,
688                seen,
689            },
690        );
691    }
692
693    /// Hover progress (0..1) for `fade` at `now`; stamps liveness.
694    pub fn value_at(&mut self, fade: &Fade, now: Instant) -> f32 {
695        let frame = self.frame;
696        match self.entries.get_mut(fade) {
697            Some(entry) => {
698                entry.seen = frame;
699                entry.value(now, Self::duration())
700            }
701            None => 0.0,
702        }
703    }
704
705    /// Once-per-frame bookkeeping: advance the frame counter, prune entries
706    /// that settled back to rest or went a full frame unread (unmounted), and
707    /// report whether any fade is still mid-flight (→ keep frames coming).
708    pub fn tick_at(&mut self, now: Instant) -> bool {
709        self.frame += 1;
710        let frame = self.frame;
711        let duration = Self::duration();
712        let mut active = false;
713        self.entries.retain(|_, entry| {
714            // Unread through the whole previous frame: the element unmounted
715            // (its leave event will never come) — drop the entry.
716            if entry.seen + 1 < frame {
717                return false;
718            }
719            let settled = entry.settled(now, duration);
720            if !settled {
721                active = true;
722            }
723            // Settled at rest — steady state, indistinguishable from absent.
724            !(settled && entry.target == 0.0)
725        });
726        active
727    }
728}
729
730thread_local! {
731    static HOVER_FADES: RefCell<HoverFades> = RefCell::new(HoverFades::default());
732}
733
734/// Hover progress (0..1) for `fade` this frame.
735pub fn hover_t(fade: &Fade) -> f32 {
736    HOVER_FADES.with(|fades| fades.borrow_mut().value_at(fade, Instant::now()))
737}
738
739/// Record a hover flip for `fade` (reduced motion snaps). Prefer
740/// [`hover_listener`], which also asks the clock for the frames to paint it.
741pub fn set_hover(fade: &Fade, hovered: bool, reduced: bool) {
742    HOVER_FADES.with(|fades| {
743        fades
744            .borrow_mut()
745            .set_at(fade, hovered, reduced, Instant::now())
746    });
747}
748
749/// An `.on_hover` listener driving the fade — pair with [`hover_t`] or
750/// [`hover_blend`] reads of the same [`Fade`] in the same element.
751///
752/// The view comes in on the `Fade` rather than being resolved here: this runs in
753/// event-dispatch context, where `Window::current_view()` asserts.
754pub fn hover_listener(fade: Fade) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
755    move |hovered, _window, cx| {
756        set_hover(&fade, *hovered, cx.reduced_motion());
757        fade.painter.lease(HOVER_FPS, HoverFades::duration(), cx);
758    }
759}
760
761/// Once-per-tick bookkeeping for the fade store, driven by the clock.
762fn tick_hover_fades() {
763    HOVER_FADES.with(|fades| {
764        fades.borrow_mut().tick_at(Instant::now());
765    });
766}
767
768/// Blend two colors by `t` the way the browser transitions them: component
769/// interpolation in sRGB with premultiplied alpha — a wash fading in from
770/// transparent brightens without passing through grey.
771pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla {
772    let t = t.clamp(0.0, 1.0);
773    if t <= 0.0 {
774        return from;
775    }
776    if t >= 1.0 {
777        return to;
778    }
779    let (f, g) = (Rgba::from(from), Rgba::from(to));
780    let a = lerp(f.a, g.a, t);
781    if a <= f32::EPSILON {
782        // Both endpoints (effectively) transparent — carry the target's hue.
783        return Hsla::from(Rgba { a: 0.0, ..g });
784    }
785    Hsla::from(Rgba {
786        r: lerp(f.r * f.a, g.r * g.a, t) / a,
787        g: lerp(f.g * f.a, g.g * g.a, t) / a,
788        b: lerp(f.b * f.a, g.b * g.a, t) / a,
789        a,
790    })
791}
792
793/// The standard hover blend: rest → hover color at this fade's progress.
794pub fn hover_blend(fade: &Fade, rest: Hsla, hover: Hsla) -> Hsla {
795    mix(rest, hover, hover_t(fade))
796}
797
798// ---------------------------------------------------------------------------
799// Speed and reduced motion
800// ---------------------------------------------------------------------------
801
802/// Process-wide motion speed, as f32 bits: every catalog timeline is multiplied
803/// by it.
804///
805/// An atomic mirror rather than a gpui global, for exactly the reason
806/// `theme::current_appearance` is one: the timelines are read from free
807/// functions deep inside element builders — [`HoverFades::duration`], the
808/// popover exit clock — that have no `cx` in scope. Speed is genuinely
809/// process-wide, one setting for every window, so a single mirror is sound.
810static SPEED: AtomicU32 = AtomicU32::new(1.0f32.to_bits());
811
812/// How far every timeline in the catalog is stretched. `1.0` is the designed
813/// speed.
814pub fn speed_scale() -> f32 {
815    f32::from_bits(SPEED.load(Ordering::Relaxed))
816}
817
818/// Stretch every catalog timeline by `scale` — `10.0` slows the 200ms pane
819/// tweens to 2s, so a screenshot burst can sample the geometry frame by frame.
820///
821/// Configuration in code, like the rest of bezel: an app that wants this on a
822/// setting, or hanging off its own theme, calls this from wherever that lives.
823/// It used to read a `BEZEL_MOTION_SCALE` environment variable, which meant the
824/// one knob in the library that no app could reach.
825///
826/// Clamped to `0.01..=100.0`; a non-finite `scale` resets to `1.0` rather than
827/// poisoning every duration with a NaN.
828pub fn set_speed(scale: f32) {
829    let scale = if scale.is_finite() {
830        scale.clamp(0.01, 100.0)
831    } else {
832        1.0
833    };
834    SPEED.store(scale.to_bits(), Ordering::Relaxed);
835}
836
837/// [`SPEED`] is process-wide, so a test that moves it must hold this lock and
838/// restore `1.0` before letting go — every fade and exit timing asserted
839/// anywhere is measured in it. The same arrangement as
840/// `theme::lock_appearance`, and public for the same reason: such tests
841/// exist in other crates too. Not part of the API.
842#[doc(hidden)]
843pub fn lock_speed() -> std::sync::MutexGuard<'static, ()> {
844    static SPEED_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
845    SPEED_LOCK.lock().unwrap_or_else(|e| e.into_inner())
846}