ui/loaders.rs
1//! Loaders: the orb cluster, the pulse loader and the gradient matrix spinners.
2//! All motion routes through `motion` pure helpers, so the math is
3//! unit-tested and these elements are testable-by-compile.
4//!
5//! [`orb`] is bezel's own vocabulary — four shapes over one period — and is
6//! what a thinking surface should reach for. The older three are grids of
7//! cells.
8//!
9//! Rendering pattern: one shared clock ([`motion::pulse_delta`]) drives the
10//! view, and per-cell offsets come from [`motion::staggered_phase`] off that
11//! single delta, so every cell stays phase-locked. Cells animate inside
12//! fixed-size slots — opacity and inner size
13//! are paint-local and never move surrounding layout. Reduced motion snaps every
14//! cell to its rest state automatically (gpui `reduce_motion`).
15
16use gpui::{App, IntoElement, ParentElement, SharedString, Styled, div, px};
17
18use motion::{self, GRADIENT_SPIN, ORB, PULSE, PULSE_STAGGER, Painter};
19use theme::{TextStyle, Theme, Typeset};
20
21pub use motion::phase::{GSPIN_DIM, GSPIN_ROW_TINTS, MATRIX_SIDE, PULSE_CELLS};
22
23/// The pulse wave loader: a row of cells pulsing opacity 0.08→1 / scale 0.9→1
24/// over 2.4s with a 0.15s stagger per cell.
25///
26/// `id` scopes the per-cell animation state — give each loader instance a
27/// distinct id.
28pub fn pulse_loader(
29 _id: &'static str,
30 theme: &Theme,
31 cell_px: f32,
32 painter: Painter,
33 cx: &mut App,
34) -> impl IntoElement {
35 let color = theme.text;
36 let slot = cell_px;
37 let delta = motion::pulse_delta(&PULSE, painter, cx);
38 div()
39 .flex()
40 .flex_row()
41 .items_center()
42 .gap(px(slot / 2.0))
43 .children((0..PULSE_CELLS).map(move |i| {
44 // Fixed slot; the animated cell breathes inside it.
45 div()
46 .size(px(slot))
47 .flex()
48 .items_center()
49 .justify_center()
50 .child({
51 let phase = motion::staggered_phase(delta, i, PULSE_STAGGER);
52 div()
53 .rounded(px(slot / 4.0))
54 .bg(color)
55 .opacity(motion::pulse_opacity(phase))
56 .size(px(slot * motion::pulse_scale(phase)))
57 })
58 }))
59}
60
61/// The gradient matrix spinner (working indicator): a 3×3 grid of round cells
62/// tinted per row from the sunrise gradient. Each cell pulses opacity once per
63/// 750ms period; the per-cell phase follows the "arrow-up" pattern (the pulse
64/// enters at the bottom edge and converges toward the top-center cell), so the
65/// wave reads as travelling upward.
66pub fn gradient_spinner(
67 _id: &'static str,
68 _theme: &Theme,
69 cell_px: f32,
70 painter: Painter,
71 cx: &mut App,
72) -> impl IntoElement {
73 let center = (MATRIX_SIDE as f32 - 1.0) / 2.0;
74 let max = MATRIX_SIDE as f32 - 1.0 + center;
75 let delta = motion::pulse_delta(&GRADIENT_SPIN, painter, cx);
76 div()
77 .flex()
78 .flex_col()
79 .gap(px(cell_px / 2.0))
80 .children((0..MATRIX_SIDE).map(move |row| {
81 let tint: gpui::Hsla = gpui::rgb(GSPIN_ROW_TINTS[row]).into();
82 div()
83 .flex()
84 .flex_row()
85 .gap(px(cell_px / 2.0))
86 .children((0..MATRIX_SIDE).map(move |col| {
87 // Distance of this cell from the wave origin, normalized
88 // into a phase offset (gradient-spin's `--gspin-phase`).
89 let d = MATRIX_SIDE as f32 - 1.0 - row as f32 + (col as f32 - center).abs();
90 let phase = if max == 0.0 { 0.0 } else { d / (max + 1.0) };
91 div()
92 .size(px(cell_px))
93 .rounded(px(cell_px / 2.0))
94 .bg(tint)
95 .opacity(motion::gspin_opacity(delta + phase, GSPIN_DIM))
96 }))
97 }))
98}
99
100/// A 2×3 miniature of [`gradient_spinner`] sized for a status-dot slot: same
101/// row tints and pulse timing, but the brightness SNAKES around the grid's
102/// perimeter (every cell of a 2×3 grid is on the ring) instead of sweeping as
103/// a vertical wave — a tiny radial chase. ~6×10px footprint at the default
104/// 2.5px cells.
105pub fn mini_gradient_spinner(
106 key: impl Into<SharedString>,
107 cell_px: f32,
108 painter: Painter,
109 cx: &mut App,
110) -> impl IntoElement {
111 const COLS: usize = 2;
112 const ROWS: usize = 3;
113 /// Clockwise ring position of each `(row, col)` cell, top-left first:
114 /// (0,0) → (0,1) → (1,1) → (2,1) → (2,0) → (1,0).
115 const RING: [[usize; COLS]; ROWS] = [[0, 1], [5, 2], [4, 3]];
116 const RING_LEN: f32 = (COLS * ROWS) as f32;
117 let _key = key.into();
118 let delta = motion::pulse_delta(&GRADIENT_SPIN, painter, cx);
119 div()
120 .flex()
121 .flex_col()
122 .gap(px(cell_px / 2.0))
123 .children((0..ROWS).map(move |row| {
124 let tint: gpui::Hsla = gpui::rgb(GSPIN_ROW_TINTS[row]).into();
125 div()
126 .flex()
127 .flex_row()
128 .gap(px(cell_px / 2.0))
129 .children((0..COLS).map(move |col| {
130 let phase = RING[row][col] as f32 / RING_LEN;
131 div()
132 .size(px(cell_px))
133 .rounded(px(cell_px / 2.0))
134 .bg(tint)
135 .opacity(motion::gspin_opacity(delta + phase, GSPIN_DIM))
136 }))
137 }))
138}
139
140/// Which orb to draw. All four share one period, one tint and one box, and
141/// differ only in how the circles are arranged and what the phase moves.
142///
143/// A parameter rather than four functions, for the reason [`crate::input::Shape`]
144/// is one: they are the same operation, and the thing that differs is an
145/// argument.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Orb {
148 /// Blobs whose sizes swing so the count you perceive changes, drifting far
149 /// enough to merge and separate. The one a thinking surface should reach
150 /// for.
151 Cluster,
152 /// Dots on a circle, brightness chasing round — the classic, with a real
153 /// circle instead of the mini spinner's 2×3 grid.
154 Ring,
155 /// Dots gathering to a single point and opening back out to the ring.
156 Converge,
157 /// Rings leaving the centre and fading before the edge. The only one that
158 /// travels outward, which is what makes it read as a signal rather than a
159 /// wait.
160 Bloom,
161}
162
163/// The orbs — **bezel's own loaders**, and the only ones here that are.
164///
165/// The three older loaders in this module are all grids of cells: a pulse row,
166/// a 3×3 matrix, a 2×3 mini. Three variations on one arrangement is a narrow
167/// vocabulary for the surface a library gets looked at through, and `phase.rs`
168/// makes the point itself: *a loading indicator is a brand surface.*
169///
170/// Everything is circles, because that is the whole vocabulary gpui gives at
171/// the pinned rev: no rotation transform, no conic gradient, and no blur filter
172/// on an element ([`crate::surface`]'s backdrop blur blurs what is *behind* a
173/// surface and cannot soften the surface itself). So the glow is a `BoxShadow`,
174/// the ring is eight positioned dots rather than a swept arc, and every
175/// position is arithmetic — all of it pure and unit-tested in
176/// [`motion::phase`].
177///
178/// One tint, from the theme's accent. In three hues this would be the gradient
179/// spinner wearing a different shape.
180pub fn orb(
181 shape: Orb,
182 key: impl Into<SharedString>,
183 size_px: f32,
184 theme: &Theme,
185 painter: Painter,
186 cx: &mut App,
187) -> impl IntoElement {
188 let _key = key.into();
189 let delta = motion::pulse_delta(&ORB, painter, cx);
190 let accent = theme.accent;
191
192 // A circle placed by its centre, since every seat below is a centre.
193 let dot = move |cx: f32, cy: f32, size: f32, opacity: f32, glow: f32| {
194 div()
195 .absolute()
196 .left(px(cx - size / 2.0))
197 .top(px(cy - size / 2.0))
198 .size(px(size))
199 .rounded_full()
200 .bg(accent.opacity(opacity))
201 .shadow(vec![gpui::BoxShadow {
202 color: accent.opacity(opacity * 0.55),
203 offset: gpui::point(px(0.0), px(0.0)),
204 blur_radius: px(glow),
205 spread_radius: px(0.0),
206 inset: false,
207 }])
208 };
209
210 let cells: Vec<gpui::Div> = match shape {
211 Orb::Cluster => (0..motion::ORBS)
212 .map(|index| {
213 // A third of a period apart, so one is always swelling while
214 // another shrinks and the silhouette never repeats.
215 let phase = motion::staggered_phase(delta, index, 1.0 / motion::ORBS as f32);
216 let (seat_x, seat_y) = motion::ORB_SEATS[index];
217 let (drift_x, drift_y) = motion::orb_drift(phase);
218 dot(
219 size_px * (seat_x + drift_x),
220 size_px * (seat_y + drift_y),
221 size_px * motion::orb_size(phase),
222 motion::orb_opacity(phase),
223 size_px * motion::orb_glow(phase),
224 )
225 })
226 .collect(),
227 Orb::Ring => (0..motion::ORB_RING_DOTS)
228 .map(|index| {
229 let phase =
230 motion::staggered_phase(delta, index, 1.0 / motion::ORB_RING_DOTS as f32);
231 let (seat_x, seat_y) = motion::orb_ring_seat(index, motion::ORB_RING_RADIUS);
232 dot(
233 size_px * seat_x,
234 size_px * seat_y,
235 size_px * motion::ORB_RING_DOT,
236 motion::orb_opacity(phase),
237 size_px * motion::orb_glow(phase) * 0.5,
238 )
239 })
240 .collect(),
241 Orb::Converge => {
242 // One phase for every dot, unlike the ring: they travel together,
243 // so the gathered frame is a single point rather than a queue.
244 let radius = motion::orb_converge_radius(delta);
245 (0..motion::ORB_RING_DOTS)
246 .map(|index| {
247 let (seat_x, seat_y) = motion::orb_ring_seat(index, radius);
248 dot(
249 size_px * seat_x,
250 size_px * seat_y,
251 size_px * motion::ORB_RING_DOT,
252 motion::orb_opacity(delta),
253 size_px * motion::orb_glow(delta) * 0.5,
254 )
255 })
256 .collect()
257 }
258 Orb::Bloom => (0..motion::ORB_BLOOM_RINGS)
259 .map(|index| {
260 let phase =
261 motion::staggered_phase(delta, index, 1.0 / motion::ORB_BLOOM_RINGS as f32);
262 let diameter = size_px * motion::orb_bloom_radius(phase);
263 let opacity = motion::orb_bloom_opacity(phase);
264 // A ring, not a disc: the border is the whole element, so this
265 // one is the odd shape out and cannot go through `dot`.
266 div()
267 .absolute()
268 .left(px((size_px - diameter) / 2.0))
269 .top(px((size_px - diameter) / 2.0))
270 .size(px(diameter))
271 .rounded_full()
272 .border(px((size_px * 0.05).max(1.0)))
273 .border_color(accent.opacity(opacity))
274 })
275 .collect(),
276 };
277
278 div().relative().size(px(size_px)).children(cells)
279}
280
281/// "L O A D I N G" — `uppercase tracking-[0.32em]`; tracking
282/// approximated with thin spaces (gpui has no letter-spacing at the pinned
283/// rev).
284pub fn loading_word(theme: &Theme) -> impl IntoElement {
285 div()
286 .text_style(TextStyle::Subheadline)
287 .text_color(theme.text_muted.opacity(0.7))
288 .child(SharedString::from(
289 "L\u{2009}O\u{2009}A\u{2009}D\u{2009}I\u{2009}N\u{2009}G",
290 ))
291}
292
293// Compile-time proof the specs referenced here stay wired to the catalog.
294const _: () = {
295 assert!(PULSE.duration_ms == 2400);
296 assert!(GRADIENT_SPIN.duration_ms == 750);
297};