Skip to main content

motion/
phase.rs

1//! Loader math — the pure phase functions behind the loading indicators.
2//!
3//! These are the curves and constants the gpui viewport animates with
4//! (`motion`, `bezel::loaders`), kept pure so any
5//! surface animates the *same* loaders rather than inventing its own spinner.
6//! A loading indicator is a brand surface; two of them that disagree read as
7//! two products.
8//!
9//! Everything is a pure function of a phase in `0..1`, so a caller can drive
10//! it from a frame delta or from wall-clock elapsed time and get identical
11//! output.
12
13/// Pulse loader period.
14pub const PULSE_MS: u64 = 2_400;
15/// Gradient matrix spinner wave period.
16pub const GRADIENT_SPIN_MS: u64 = 750;
17
18/// Cells in the pulse wave loader.
19pub const PULSE_CELLS: usize = 5;
20/// Side length of the gradient spinner matrix.
21pub const MATRIX_SIDE: usize = 3;
22
23/// Pulse loader cells rest at this opacity between pulses.
24pub const PULSE_MIN_OPACITY: f32 = 0.08;
25/// …and at this scale.
26pub const PULSE_MIN_SCALE: f32 = 0.9;
27/// Per-cell stagger, as a fraction of the pulse period (0.15s of 2.4s).
28pub const PULSE_STAGGER: f32 = 0.15 / 2.4;
29
30/// Per-row tints of the gradient matrix spinner — a "sunrise" gradient
31/// sampled at each row: cool blue at the top, through amber, to pink.
32pub const GSPIN_ROW_TINTS: [u32; MATRIX_SIDE] = [0xB6D3EF, 0xEDB185, 0xF888A0];
33/// Opacity a gradient-spinner cell rests at between pulses.
34pub const GSPIN_DIM: f32 = 0.1;
35
36/// Clockwise ring position of each `(row, col)` cell of the 2×3 mini spinner,
37/// top-left first: (0,0) → (0,1) → (1,1) → (2,1) → (2,0) → (1,0). Every cell of
38/// a 2×3 grid is on the ring, so the brightness chases around it.
39pub const MINI_RING: [[usize; 2]; 3] = [[0, 1], [5, 2], [4, 3]];
40/// Cells in the mini spinner's ring.
41pub const MINI_RING_LEN: f32 = 6.0;
42
43/// Orb period. Slower than either spinner: these breathe rather than tick, and
44/// a tick at this size reads as impatience.
45pub const ORB_MS: u64 = 2_000;
46
47/// Orbs in the [`ORB_SEATS`] cluster.
48pub const ORBS: usize = 3;
49/// Where each cluster orb sits, as `(x, y)` fractions of the box — a triangle,
50/// so the group reads as one object and still fills a square slot.
51pub const ORB_SEATS: [(f32, f32); ORBS] = [(0.36, 0.36), (0.64, 0.32), (0.48, 0.66)];
52/// A cluster orb's diameter at its smallest and largest, as fractions of the
53/// box. The swing is the whole point: at the bottom an orb is nearly gone, so
54/// the *count* you perceive changes as they trade places. A fixed size would
55/// leave the silhouette constant and the thing would read as three dots
56/// dimming.
57pub const ORB_MIN_SIZE: f32 = 0.14;
58pub const ORB_MAX_SIZE: f32 = 0.62;
59/// How far a cluster orb wanders from its seat, as a fraction of the box. Wide
60/// enough that neighbours overlap at one end of the walk and separate at the
61/// other.
62pub const ORB_DRIFT: f32 = 0.13;
63/// An orb never goes out entirely — the cluster dims, it does not blink.
64pub const ORB_MIN_OPACITY: f32 = 0.35;
65/// Glow radius at rest and at full breath, as fractions of the box.
66pub const ORB_GLOW_MIN: f32 = 0.10;
67pub const ORB_GLOW_MAX: f32 = 0.40;
68
69/// Dots in the [`orb_ring_seat`] ring.
70pub const ORB_RING_DOTS: usize = 8;
71/// The ring's radius and its dot diameter, as fractions of the box.
72pub const ORB_RING_RADIUS: f32 = 0.34;
73pub const ORB_RING_DOT: f32 = 0.16;
74
75/// Rings in flight at once in the bloom.
76pub const ORB_BLOOM_RINGS: usize = 3;
77/// Where a bloom ring starts and ends, as fractions of the box.
78pub const ORB_BLOOM_MIN: f32 = 0.16;
79pub const ORB_BLOOM_MAX: f32 = 1.0;
80
81// The swing has to be visible or the cluster is three dots dimming: an orb at
82// the trough is at most a quarter the diameter of one at the crest. A compile
83// error rather than a test, since both sides are constants.
84const _: () = assert!(ORB_MAX_SIZE > ORB_MIN_SIZE * 4.0);
85
86/// One cluster orb's opacity as it breathes: [`ORB_MIN_OPACITY`] → 1 → back.
87pub fn orb_opacity(phase: f32) -> f32 {
88    lerp(ORB_MIN_OPACITY, 1.0, pulse_wave(phase))
89}
90
91/// One cluster orb's diameter, as a fraction of the box.
92pub fn orb_size(phase: f32) -> f32 {
93    lerp(ORB_MIN_SIZE, ORB_MAX_SIZE, pulse_wave(phase))
94}
95
96/// One cluster orb's glow radius, as a fraction of the box. In step with the
97/// opacity, because a glow that peaks off-beat reads as two lights rather than
98/// one breathing.
99pub fn orb_glow(phase: f32) -> f32 {
100    lerp(ORB_GLOW_MIN, ORB_GLOW_MAX, pulse_wave(phase))
101}
102
103/// How far a cluster orb has drifted from its seat, as `(dx, dy)` fractions of
104/// the box — a small circle walked once per period.
105///
106/// Being a circle, the drift returns exactly to zero every period, so nothing
107/// accumulates however long it runs.
108pub fn orb_drift(phase: f32) -> (f32, f32) {
109    let angle = phase * std::f32::consts::TAU;
110    (ORB_DRIFT * angle.cos(), ORB_DRIFT * angle.sin())
111}
112
113/// Where ring dot `index` sits on a circle of `radius`, as `(x, y)` fractions
114/// of the box. Twelve o'clock first, going clockwise.
115///
116/// The radius is an argument because two shapes want the same circle: the ring
117/// holds it still and the converge pulses it. Two functions here would be one
118/// function and a number.
119pub fn orb_ring_seat(index: usize, radius: f32) -> (f32, f32) {
120    let angle =
121        index as f32 / ORB_RING_DOTS as f32 * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
122    (0.5 + radius * angle.cos(), 0.5 + radius * angle.sin())
123}
124
125/// The converge's radius at `phase`: in to nothing, back out to the ring.
126///
127/// Every dot shares this one radius, so they arrive at the centre together and
128/// stack into a single point — the frame that makes this read as a gathering
129/// rather than as a ring that shrank.
130pub fn orb_converge_radius(phase: f32) -> f32 {
131    lerp(0.0, ORB_RING_RADIUS, pulse_wave(phase))
132}
133
134/// A bloom ring's radius, as a fraction of the box: out from
135/// [`ORB_BLOOM_MIN`] to [`ORB_BLOOM_MAX`] once per period.
136pub fn orb_bloom_radius(phase: f32) -> f32 {
137    lerp(ORB_BLOOM_MIN, ORB_BLOOM_MAX, phase.rem_euclid(1.0))
138}
139
140/// A bloom ring's opacity: full as it leaves the centre, gone by the edge —
141/// squared, so it holds its brightness through the middle of the travel and
142/// then goes quickly, which is what keeps the ring from looking like a
143/// dissolving circle.
144pub fn orb_bloom_opacity(phase: f32) -> f32 {
145    let t = 1.0 - phase.rem_euclid(1.0);
146    t * t
147}
148
149/// Linear interpolation.
150pub fn lerp(from: f32, to: f32, t: f32) -> f32 {
151    from + (to - from) * t
152}
153
154/// A cell's phase, given the loader's raw phase and the cell's index.
155pub fn staggered_phase(raw_delta: f32, index: usize, stagger: f32) -> f32 {
156    (raw_delta - index as f32 * stagger).rem_euclid(1.0)
157}
158
159/// Cosine pulse: 0 at phase 0, 1 at phase 0.5, back to 0 at phase 1.
160pub fn pulse_wave(phase: f32) -> f32 {
161    0.5 - 0.5 * (phase * std::f32::consts::TAU).cos()
162}
163
164/// Pulse loader cell opacity for a phase: 0.08 → 1 → 0.08.
165pub fn pulse_opacity(phase: f32) -> f32 {
166    PULSE_MIN_OPACITY + (1.0 - PULSE_MIN_OPACITY) * pulse_wave(phase)
167}
168
169/// Pulse loader cell scale for a phase: 0.9 → 1 → 0.9.
170pub fn pulse_scale(phase: f32) -> f32 {
171    PULSE_MIN_SCALE + (1.0 - PULSE_MIN_SCALE) * pulse_wave(phase)
172}
173
174/// Gradient-spin cell opacity for a local phase `t` (0..1 of the period),
175/// ported from the `gradient-spin-pulse` keyframes: full at the cycle
176/// start, easing down to `dim` by 45%, resting at `dim` until 92%, then rising
177/// back to full — the per-cell phase offset sweeps this pulse across the grid.
178pub fn gspin_opacity(t: f32, dim: f32) -> f32 {
179    let t = t.rem_euclid(1.0);
180    if t < 0.45 {
181        lerp(1.0, dim, t / 0.45)
182    } else if t < 0.92 {
183        dim
184    } else {
185        lerp(dim, 1.0, (t - 0.92) / 0.08)
186    }
187}
188
189/// The phase offset of a `(row, col)` cell in the 3×3 gradient spinner: the
190/// pulse enters at the bottom edge and converges toward the top-centre cell, so
191/// the wave reads as travelling upward.
192pub fn gspin_cell_phase(row: usize, col: usize) -> f32 {
193    let centre = (MATRIX_SIDE as f32 - 1.0) / 2.0;
194    let max = MATRIX_SIDE as f32 - 1.0 + centre;
195    let d = MATRIX_SIDE as f32 - 1.0 - row as f32 + (col as f32 - centre).abs();
196    if max == 0.0 { 0.0 } else { d / (max + 1.0) }
197}