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