Skip to main content

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