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. The
20//! [`set_reduced_motion`]/[`reduced_motion`] wrappers make it a single global
21//! switch; pure 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; see the module report in ARCHITECTURE §4 follow-ups.
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, ElementId, EntityId, Global, Hsla, IntoElement, Rgba,
40    SharedString, Styled, Window, px,
41};
42
43pub use gpui::AnimationExt;
44
45pub mod phase;
46
47// ---------------------------------------------------------------------------
48// Pulse clock — throttled drive for the repeating loaders
49// ---------------------------------------------------------------------------
50
51/// Repeat-tick interval for the pulse/spinner loaders (~30fps).
52///
53/// The loaders used to run as gpui `with_animation(...repeating...)` elements,
54/// which request a redraw every display frame for as long as they are mounted
55/// — one Working session row pinned the whole window at 120Hz (measured 36%
56/// CPU on an M-series laptop, with the always-hot Metal pipeline holding
57/// hundreds of MB of graphics buffers). A shared 30fps clock is visually
58/// equivalent for these chunky cell waves at a quarter of the redraws, and a
59/// window with no spinner mounted schedules nothing at all.
60const PULSE_TICK: Duration = Duration::from_millis(33);
61
62/// How long a view stays on the tick list after its last spinner paint. One
63/// lease outlives a few missed frames; an unmounted spinner stops renewing and
64/// the view drops off, letting the clock park.
65const PULSE_LEASE: Duration = Duration::from_millis(300);
66
67struct PulseClock {
68    epoch: Instant,
69    leases: HashMap<EntityId, Instant>,
70    running: bool,
71}
72
73impl Global for PulseClock {}
74
75impl Default for PulseClock {
76    fn default() -> Self {
77        Self {
78            epoch: Instant::now(),
79            leases: HashMap::new(),
80            running: false,
81        }
82    }
83}
84
85/// Current phase `[0,1)` of a repeating spec, plus a lease that keeps the
86/// calling view re-rendering at [`PULSE_TICK`] while its spinner stays
87/// mounted. All cells across all views share one epoch, so multi-instance
88/// loaders stay phase-locked. Reduced motion returns a static 0 and schedules
89/// nothing.
90pub fn pulse_delta(spec: &MotionSpec, view: EntityId, cx: &mut App) -> f32 {
91    if cx.reduce_motion() {
92        return 0.0;
93    }
94    let clock = cx.default_global::<PulseClock>();
95    clock.leases.insert(view, Instant::now() + PULSE_LEASE);
96    let period = spec.total().as_secs_f32();
97    let phase = (clock.epoch.elapsed().as_secs_f32() / period).fract();
98    if !clock.running {
99        clock.running = true;
100        cx.spawn(async move |cx| {
101            loop {
102                cx.background_executor().timer(PULSE_TICK).await;
103                let parked = cx.update(|cx| {
104                    let clock = cx.default_global::<PulseClock>();
105                    let now = Instant::now();
106                    clock.leases.retain(|_, until| *until > now);
107                    if clock.leases.is_empty() {
108                        clock.running = false;
109                        return true;
110                    }
111                    let views: Vec<EntityId> = clock.leases.keys().copied().collect();
112                    for view in views {
113                        cx.notify(view);
114                    }
115                    false
116                });
117                if parked {
118                    break;
119                }
120            }
121        })
122        .detach();
123    }
124    phase
125}
126
127// ---------------------------------------------------------------------------
128// Cubic bezier
129// ---------------------------------------------------------------------------
130
131/// A CSS `cubic-bezier(x1, y1, x2, y2)` timing function (endpoints fixed at
132/// (0,0) and (1,1)). Evaluation solves x(t) = input by Newton iteration with a
133/// bisection fallback — the standard UnitBezier approach.
134#[derive(Debug, Clone, Copy, PartialEq)]
135pub struct CubicBezier {
136    pub x1: f32,
137    pub y1: f32,
138    pub x2: f32,
139    pub y2: f32,
140}
141
142impl CubicBezier {
143    pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
144        Self { x1, y1, x2, y2 }
145    }
146
147    fn coefficients(a: f32, b: f32) -> (f32, f32, f32) {
148        let c = 3.0 * a;
149        let bb = 3.0 * (b - a) - c;
150        let aa = 1.0 - c - bb;
151        (aa, bb, c)
152    }
153
154    fn sample_x(&self, t: f32) -> f32 {
155        let (a, b, c) = Self::coefficients(self.x1, self.x2);
156        ((a * t + b) * t + c) * t
157    }
158
159    fn sample_y(&self, t: f32) -> f32 {
160        let (a, b, c) = Self::coefficients(self.y1, self.y2);
161        ((a * t + b) * t + c) * t
162    }
163
164    fn sample_x_derivative(&self, t: f32) -> f32 {
165        let (a, b, c) = Self::coefficients(self.x1, self.x2);
166        (3.0 * a * t + 2.0 * b) * t + c
167    }
168
169    /// Curve parameter `t` for a given progress `x` (both 0..1).
170    fn solve_t_for_x(&self, x: f32) -> f32 {
171        // Newton–Raphson.
172        let mut t = x;
173        for _ in 0..8 {
174            let err = self.sample_x(t) - x;
175            if err.abs() < 1e-6 {
176                return t;
177            }
178            let d = self.sample_x_derivative(t);
179            if d.abs() < 1e-6 {
180                break;
181            }
182            t -= err / d;
183        }
184        // Bisection fallback (x(t) is monotonic for valid CSS beziers).
185        let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
186        for _ in 0..32 {
187            let mid = (lo + hi) / 2.0;
188            if self.sample_x(mid) < x {
189                lo = mid
190            } else {
191                hi = mid
192            }
193        }
194        (lo + hi) / 2.0
195    }
196
197    /// Eased output for input progress `x ∈ [0,1]` (clamped).
198    pub fn eval(&self, x: f32) -> f32 {
199        if x <= 0.0 {
200            return 0.0;
201        }
202        if x >= 1.0 {
203            return 1.0;
204        }
205        // f32 rounding can push sample_y a hair past 1.0 (observed 1.000000119
206        // near the end of menu animations); gpui's animation element asserts
207        // `delta ∈ [0,1]` and aborts, so clamp the output hard.
208        self.sample_y(self.solve_t_for_x(x)).clamp(0.0, 1.0)
209    }
210
211    /// This curve as a gpui easing closure.
212    pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
213        move |x| self.eval(x)
214    }
215}
216
217/// The signature entrance curve — CSS `cubic-bezier(0.16, 1, 0.3, 1)`.
218pub const EASE_OUT_EXPO: CubicBezier = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
219/// CSS `ease-out` — width/height transitions.
220pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
221/// CSS `ease` — quick fades, menu/dialog pops.
222pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
223/// Sidebar resort glide — CSS `cubic-bezier(0.22, 1, 0.36, 1)` (used from M3b).
224pub const EASE_RESORT: CubicBezier = CubicBezier::new(0.22, 1.0, 0.36, 1.0);
225/// CSS `ease-in-out` — the transcript scroll glide (browser smooth-scroll
226/// shape: gentle start, cruise, gentle landing).
227pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
228
229// ---------------------------------------------------------------------------
230// Motion specs (the catalog)
231// ---------------------------------------------------------------------------
232
233/// One catalog entry: duration + optional delay + curve. The delay is folded into
234/// the gpui animation timeline (gpui `Animation` has no native delay): the
235/// animation runs for `delay + duration` and [`progress`](Self::progress) holds 0
236/// until the delay has elapsed.
237#[derive(Debug, Clone, Copy, PartialEq)]
238pub struct MotionSpec {
239    pub duration_ms: u64,
240    pub delay_ms: u64,
241    pub curve: CubicBezier,
242}
243
244impl MotionSpec {
245    pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
246        Self {
247            duration_ms,
248            delay_ms: 0,
249            curve,
250        }
251    }
252
253    pub const fn with_delay(mut self, delay_ms: u64) -> Self {
254        self.delay_ms = delay_ms;
255        self
256    }
257
258    /// Wall-clock span of the whole timeline (delay + duration).
259    pub fn total(&self) -> Duration {
260        Duration::from_millis(self.delay_ms + self.duration_ms)
261    }
262
263    /// Eased progress (0..1) for a raw timeline delta (0..1 across [`total`](Self::total)).
264    /// Pure — unit-testable without a window.
265    pub fn progress(&self, raw_delta: f32) -> f32 {
266        let total = (self.delay_ms + self.duration_ms) as f32;
267        if total <= 0.0 || self.duration_ms == 0 {
268            return 1.0;
269        }
270        let t =
271            (raw_delta.clamp(0.0, 1.0) * total - self.delay_ms as f32) / self.duration_ms as f32;
272        self.curve.eval(t.clamp(0.0, 1.0))
273    }
274
275    /// A oneshot gpui [`Animation`] for this spec (delay folded in).
276    /// Wall-clock span honors [`speed_scale`] (measurement knob).
277    pub fn animation(&self) -> Animation {
278        let spec = *self;
279        Animation::new(spec.total().mul_f32(speed_scale())).with_easing(move |d| spec.progress(d))
280    }
281
282    /// A repeating gpui [`Animation`] with linear easing over the raw period —
283    /// for the pulse/wave loaders whose per-cell easing happens in the animator.
284    pub fn repeating(&self) -> Animation {
285        Animation::new(self.total()).repeat()
286    }
287}
288
289/// Entrances: 0.5s expo-out fade + 4px rise.
290pub const FADE_IN: MotionSpec = MotionSpec::new(500, EASE_OUT_EXPO);
291/// Quick fade: 0.15s.
292pub const FADE_QUICK: MotionSpec = MotionSpec::new(150, EASE);
293/// Popover-in: 0.14s (scale 0.96 approximated, translateY −2).
294pub const MENU_IN: MotionSpec = MotionSpec::new(140, EASE);
295/// Popover-out: 0.1s — quicker than the entrance (exits should get out of the
296/// way; matches the Radix convention of a shorter close than open).
297pub const MENU_OUT: MotionSpec = MotionSpec::new(100, EASE);
298/// Dialog-in: 0.18s (scale 0.96→1 approximated).
299pub const DIALOG_IN: MotionSpec = MotionSpec::new(180, EASE);
300/// Boot splash exit: 0.5s fade + 6px lift after a 0.15s hold.
301pub const SPLASH_OUT: MotionSpec = MotionSpec::new(500, EASE).with_delay(150);
302/// Sidebar / pane width+height transitions: 200ms ease-out.
303pub const RESIZE: MotionSpec = MotionSpec::new(200, EASE_OUT);
304/// Terminal tab drag-reorder sliding transforms: 150ms (§1.10).
305pub const TAB_SLIDE: MotionSpec = MotionSpec::new(150, EASE_OUT);
306/// Diff-pane per-file collapse: 180ms height (§1.11).
307pub const COLLAPSE: MotionSpec = MotionSpec::new(180, EASE_OUT);
308/// Diff-pane chevron rotate: 200ms (§1.11; approximated as a crossfade — gpui
309/// divs have no rotation transform at the pinned rev, same caveat as scale).
310pub const CHEVRON: MotionSpec = MotionSpec::new(200, EASE);
311/// Rail-tick / scroll-to-row glide: 500ms ease-in-out over the whole distance
312/// (Electron parity — the original rail rode the browser's native smooth
313/// scroll, a fixed-duration gentle ease, never percent-of-remaining).
314pub const SCROLL_GLIDE: MotionSpec = MotionSpec::new(500, EASE_IN_OUT);
315/// Tailwind's default transition curve — CSS `cubic-bezier(0.4, 0, 0.2, 1)`
316/// (`transition-colors` et al. carry it unless overridden).
317pub const EASE_TAILWIND: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0);
318/// CSS `transition-colors` default: 150ms over [`EASE_TAILWIND`] — the temporal
319/// blend every interactive hover wash rides in the original.
320pub const HOVER_FADE: MotionSpec = MotionSpec::new(150, EASE_TAILWIND);
321/// Pulse loader period: 2.4s.
322pub const PULSE: MotionSpec = MotionSpec::new(2400, EASE);
323/// Gradient matrix spinner wave period: 750ms.
324pub const GRADIENT_SPIN: MotionSpec = MotionSpec::new(750, EASE);
325/// Orb cluster breath: 2s, and `EASE_IN_OUT` because a breath has no edges —
326/// the two spinners tick, this one swells.
327pub const ORB: MotionSpec = MotionSpec::new(phase::ORB_MS, EASE_IN_OUT);
328
329// ---------------------------------------------------------------------------
330// Element helpers (paint-layer entrances/exits)
331// ---------------------------------------------------------------------------
332
333/// Standard entrance: opacity 0→1 + translateY 4→0 over [`FADE_IN`].
334pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
335where
336    E: Styled + IntoElement + 'static,
337{
338    element.with_animation(id, FADE_IN.animation(), |el, t| {
339        el.relative().opacity(t).top(px(4.0 * (1.0 - t)))
340    })
341}
342
343/// Quick opacity-only fade over [`FADE_QUICK`].
344pub fn fade_quick<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
345where
346    E: Styled + IntoElement + 'static,
347{
348    element.with_animation(id, FADE_QUICK.animation(), |el, t| el.opacity(t))
349}
350
351/// Popover entrance: fade + translateY −2→0 over [`MENU_IN`].
352/// (the original also scales 0.96→1; divs have no scale transform in gpui — approximated.)
353pub fn menu_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
354where
355    E: Styled + IntoElement + 'static,
356{
357    element.with_animation(id, MENU_IN.animation(), |el, t| {
358        el.relative()
359            .opacity(0.3 + 0.7 * t)
360            .top(px(-2.0 * (1.0 - t)))
361    })
362}
363
364/// Popover exit: the reverse of [`menu_in`] — fade to 0 + translateY 0→−2 over
365/// [`MENU_OUT`]. Unlike the entrances, the eased progress `t` comes from the
366/// caller (computed off `bezel::popover::Popup`'s closing instant at render
367/// time): `with_animation`'s element-id-keyed clock replays from 0 on remount
368/// (the hover-blend comment's warning), and a replay mid-exit is a full-opacity
369/// flash. The wall-clock progress is monotonic by construction; the animation
370/// wrapper here only pumps frames for the exit's span, its own delta unused.
371pub fn menu_out<E>(id: impl Into<ElementId>, t: f32, element: E) -> AnimationElement<E>
372where
373    E: Styled + IntoElement + 'static,
374{
375    element.with_animation(id, MENU_OUT.animation(), move |el, _| {
376        el.relative().opacity(1.0 - t).top(px(-2.0 * t))
377    })
378}
379
380/// Dialog entrance over [`DIALOG_IN`] (scale approximated with fade + 2px rise).
381pub fn dialog_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
382where
383    E: Styled + IntoElement + 'static,
384{
385    element.with_animation(id, DIALOG_IN.animation(), |el, t| {
386        el.relative().opacity(t).top(px(2.0 * (1.0 - t)))
387    })
388}
389
390/// Boot-splash exit: hold 150ms, then fade out + lift 6px over 500ms.
391pub fn splash_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
392where
393    E: Styled + IntoElement + 'static,
394{
395    element.with_animation(id, SPLASH_OUT.animation(), |el, t| {
396        el.opacity(1.0 - t).top(px(-6.0 * t))
397    })
398}
399
400// ---------------------------------------------------------------------------
401// Loader math (pure; rendered by bezel::loaders)
402// ---------------------------------------------------------------------------
403
404// The loader constants and math live in `crate::phase` (pure phase
405// functions); this crate animates them with gpui.
406pub use crate::phase::{
407    ORB_BLOOM_RINGS, ORB_RING_DOT, ORB_RING_DOTS, ORB_RING_RADIUS, ORB_SEATS, ORBS,
408    PULSE_MIN_OPACITY, PULSE_MIN_SCALE, PULSE_STAGGER, gspin_opacity, orb_bloom_opacity,
409    orb_bloom_radius, orb_converge_radius, orb_drift, orb_glow, orb_opacity, orb_ring_seat,
410    orb_size, pulse_opacity, pulse_scale, pulse_wave, staggered_phase,
411};
412
413/// Gradient-matrix spinner wave: intensity (0..1) of cell `wave_index` out of
414/// `wave_count` diagonals, at raw delta `raw_delta` of the 750ms period. The wave
415/// front travels across diagonals once per period.
416pub fn matrix_wave(raw_delta: f32, wave_index: usize, wave_count: usize) -> f32 {
417    let count = wave_count.max(1) as f32;
418    pulse_wave(staggered_phase(raw_delta, wave_index, 1.0 / count))
419}
420
421/// Linear interpolation (layout tweens).
422pub fn lerp(from: f32, to: f32, t: f32) -> f32 {
423    from + (to - from) * t
424}
425
426// ---------------------------------------------------------------------------
427// Hover color fades (CSS `transition-colors` parity)
428// ---------------------------------------------------------------------------
429//
430// gpui `.hover()` styles snap by construction — the style applies the frame
431// the pointer enters. The original CSS puts Tailwind `transition-colors`
432// (150ms, cubic-bezier(0.4, 0, 0.2, 1)) on every interactive wash, so hover
433// states FADE. This is the manual-drive tween for that (the shell `WidthTween`
434// pattern — never `with_animation`, whose element-id-keyed clock replays on
435// remount): a per-element-key hover progress, advanced from wall time on each
436// evaluation, with the render tail requesting frames while any fade is
437// mid-flight.
438//
439// The store is a main-thread `thread_local` rather than a gpui Global so the
440// many free-function element builders (window-control buttons, popover menu
441// rows, markdown code blocks) can blend colors without threading `cx` through
442// every signature. All access happens on the UI thread (element builders,
443// mouse listeners, the render tail).
444//
445// Staleness: an element that unmounts mid-hover never gets its leave event, so
446// entries are stamped with a frame counter on every read and pruned by
447// [`hover_fades_active`] (the once-per-frame tick) when a full frame passes
448// without a read — a reopened menu never inherits a dead entry's wash.
449
450/// One element's hover fade: progress runs `origin → target` over
451/// [`HOVER_FADE`], re-anchored at `origin` whenever the pointer flips
452/// direction mid-flight so the blend is continuous.
453#[derive(Debug, Clone, Copy)]
454pub struct FadeEntry {
455    origin: f32,
456    target: f32,
457    started: Instant,
458    /// Frame counter at the last read (liveness stamp — see module notes).
459    seen: u64,
460}
461
462impl FadeEntry {
463    fn value(&self, now: Instant, duration: Duration) -> f32 {
464        let elapsed = now.saturating_duration_since(self.started);
465        if duration.is_zero() || elapsed >= duration {
466            return self.target;
467        }
468        let raw = elapsed.as_secs_f32() / duration.as_secs_f32();
469        lerp(self.origin, self.target, HOVER_FADE.curve.eval(raw))
470    }
471
472    fn settled(&self, now: Instant, duration: Duration) -> bool {
473        self.origin == self.target || now.saturating_duration_since(self.started) >= duration
474    }
475}
476
477/// Per-key hover progress store. Pure core (explicit `now`) — unit-testable;
478/// the thread-local wrappers below feed it wall time.
479#[derive(Default)]
480pub struct HoverFades {
481    pub entries: HashMap<String, FadeEntry>,
482    frame: u64,
483}
484
485impl HoverFades {
486    pub fn duration() -> Duration {
487        HOVER_FADE.total().mul_f32(speed_scale())
488    }
489
490    /// Pointer entered (`hovered`) or left the element behind `key`. Reduced
491    /// motion snaps straight to the endpoint.
492    pub fn set_at(&mut self, key: &str, hovered: bool, reduced: bool, now: Instant) {
493        let target = if hovered { 1.0 } else { 0.0 };
494        let duration = Self::duration();
495        let current = self
496            .entries
497            .get(key)
498            .map(|e| e.value(now, duration))
499            .unwrap_or(0.0);
500        if target == 0.0 && !self.entries.contains_key(key) {
501            return; // never-hovered element reporting a leave — nothing to do
502        }
503        let origin = if reduced { target } else { current };
504        let seen = self.frame;
505        self.entries.insert(
506            key.to_string(),
507            FadeEntry {
508                origin,
509                target,
510                started: now,
511                seen,
512            },
513        );
514    }
515
516    /// Hover progress (0..1) for `key` at `now`; stamps liveness.
517    pub fn value_at(&mut self, key: &str, now: Instant) -> f32 {
518        let frame = self.frame;
519        match self.entries.get_mut(key) {
520            Some(entry) => {
521                entry.seen = frame;
522                entry.value(now, Self::duration())
523            }
524            None => 0.0,
525        }
526    }
527
528    /// Once-per-frame bookkeeping: advance the frame counter, prune entries
529    /// that settled back to rest or went a full frame unread (unmounted), and
530    /// report whether any fade is still mid-flight (→ keep frames coming).
531    pub fn tick_at(&mut self, now: Instant) -> bool {
532        self.frame += 1;
533        let frame = self.frame;
534        let duration = Self::duration();
535        let mut active = false;
536        self.entries.retain(|_, entry| {
537            // Unread through the whole previous frame: the element unmounted
538            // (its leave event will never come) — drop the entry.
539            if entry.seen + 1 < frame {
540                return false;
541            }
542            let settled = entry.settled(now, duration);
543            if !settled {
544                active = true;
545            }
546            // Settled at rest — steady state, indistinguishable from absent.
547            !(settled && entry.target == 0.0)
548        });
549        active
550    }
551}
552
553thread_local! {
554    static HOVER_FADES: RefCell<HoverFades> = RefCell::new(HoverFades::default());
555}
556
557/// Hover progress (0..1) for `key` this frame.
558pub fn hover_t(key: &str) -> f32 {
559    HOVER_FADES.with(|fades| fades.borrow_mut().value_at(key, Instant::now()))
560}
561
562/// Record a hover flip for `key` (reduced motion snaps).
563pub fn set_hover(key: &str, hovered: bool, reduced: bool) {
564    HOVER_FADES.with(|fades| {
565        fades
566            .borrow_mut()
567            .set_at(key, hovered, reduced, Instant::now())
568    });
569}
570
571/// An `.on_hover` listener driving the fade for `key` — pair with
572/// [`hover_t`]/[`hover_blend`] reads of the same key in the same element.
573pub fn hover_listener(
574    key: impl Into<SharedString>,
575) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
576    let key = key.into();
577    move |hovered, window, cx| {
578        set_hover(&key, *hovered, reduced_motion(cx));
579        // Event-dispatch context: `request_animation_frame` is draw-phase-only
580        // (it resolves the current view) — `refresh` marks the whole window
581        // dirty, the root render re-evaluates the blend and keeps frames
582        // coming via its tail while the fade is mid-flight.
583        window.refresh();
584    }
585}
586
587/// Frame-drive hook: call ONCE per window frame, from the app's root render:
588///
589/// ```ignore
590/// if motion::hover_fades_active() {
591///     window.request_animation_frame();
592/// }
593/// ```
594///
595/// **Not optional.** A hover fade is a colour computed at paint time rather than
596/// an animation element that drives itself: [`hover_listener`] dirties the
597/// window once as the pointer crosses, and every frame after that one has to be
598/// asked for. An app that skips this paints the blend's first frame — at rest —
599/// and then holds it until something unrelated repaints, which looks like a wash
600/// that sticks and then jumps rather than one that is simply off.
601///
602/// It is also the tick: the frame counter it advances is what evicts fades whose
603/// elements have gone away, so skipping it leaks an entry per hovered element.
604pub fn hover_fades_active() -> bool {
605    HOVER_FADES.with(|fades| fades.borrow_mut().tick_at(Instant::now()))
606}
607
608/// Blend two colors by `t` the way the browser transitions them: component
609/// interpolation in sRGB with premultiplied alpha — a wash fading in from
610/// transparent brightens without passing through grey.
611pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla {
612    let t = t.clamp(0.0, 1.0);
613    if t <= 0.0 {
614        return from;
615    }
616    if t >= 1.0 {
617        return to;
618    }
619    let (f, g) = (Rgba::from(from), Rgba::from(to));
620    let a = lerp(f.a, g.a, t);
621    if a <= f32::EPSILON {
622        // Both endpoints (effectively) transparent — carry the target's hue.
623        return Hsla::from(Rgba { a: 0.0, ..g });
624    }
625    Hsla::from(Rgba {
626        r: lerp(f.r * f.a, g.r * g.a, t) / a,
627        g: lerp(f.g * f.a, g.g * g.a, t) / a,
628        b: lerp(f.b * f.a, g.b * g.a, t) / a,
629        a,
630    })
631}
632
633/// The standard hover blend: rest → hover color at `key`'s current progress.
634pub fn hover_blend(key: &str, rest: Hsla, hover: Hsla) -> Hsla {
635    mix(rest, hover, hover_t(key))
636}
637
638// ---------------------------------------------------------------------------
639// Speed and reduced motion
640// ---------------------------------------------------------------------------
641
642/// Process-wide motion speed, as f32 bits: every catalog timeline is multiplied
643/// by it.
644///
645/// An atomic mirror rather than a gpui global, for exactly the reason
646/// `theme::current_appearance` is one: the timelines are read from free
647/// functions deep inside element builders — [`HoverFades::duration`], the
648/// popover exit clock — that have no `cx` in scope. Speed is genuinely
649/// process-wide, one setting for every window, so a single mirror is sound.
650static SPEED: AtomicU32 = AtomicU32::new(1.0f32.to_bits());
651
652/// How far every timeline in the catalog is stretched. `1.0` is the designed
653/// speed.
654pub fn speed_scale() -> f32 {
655    f32::from_bits(SPEED.load(Ordering::Relaxed))
656}
657
658/// Stretch every catalog timeline by `scale` — `10.0` slows the 200ms pane
659/// tweens to 2s, so a screenshot burst can sample the geometry frame by frame.
660///
661/// Configuration in code, like the rest of bezel: an app that wants this on a
662/// setting, or hanging off its own theme, calls this from wherever that lives.
663/// It used to read a `BEZEL_MOTION_SCALE` environment variable, which meant the
664/// one knob in the library that no app could reach.
665///
666/// Clamped to `0.01..=100.0`; a non-finite `scale` resets to `1.0` rather than
667/// poisoning every duration with a NaN.
668pub fn set_speed(scale: f32) {
669    let scale = if scale.is_finite() {
670        scale.clamp(0.01, 100.0)
671    } else {
672        1.0
673    };
674    SPEED.store(scale.to_bits(), Ordering::Relaxed);
675}
676
677/// [`SPEED`] is process-wide, so a test that moves it must hold this lock and
678/// restore `1.0` before letting go — every fade and exit timing asserted
679/// anywhere is measured in it. The same arrangement as
680/// `theme::lock_appearance`, and public for the same reason: such tests
681/// exist in other crates too. Not part of the API.
682#[doc(hidden)]
683pub fn lock_speed() -> std::sync::MutexGuard<'static, ()> {
684    static SPEED_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
685    SPEED_LOCK.lock().unwrap_or_else(|e| e.into_inner())
686}
687
688/// Global reduced-motion flag. gpui snaps every `with_animation` element when
689/// set (end state for oneshots, rest state for loops) and schedules no frames.
690pub fn set_reduced_motion(cx: &mut App, reduced: bool) {
691    cx.set_reduce_motion(reduced);
692}
693
694/// Read the global reduced-motion flag.
695pub fn reduced_motion(cx: &App) -> bool {
696    cx.reduce_motion()
697}