1use crate::motion::{AmbientLoopSpec, MotionTokens, VisibleAnimationExt as _};
11use gpui::InteractiveElement as _;
12use gpui::{
13 App, ElementId, IntoElement, ParentElement as _, Pixels, RenderOnce, StyleRefinement, Styled,
14 Window, div, px,
15};
16use gpui_component::{ActiveTheme as _, StyledExt as _};
17
18const N: usize = 3;
21
22const STAGE: f32 = 28.0;
24
25const PITCH_STAGE: f32 = 6.5;
27
28const CYCLE_MS: u64 = AmbientLoopSpec::ORB_LATTICE.period_millis();
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum OrbVariant {
37 #[default]
39 Radial,
40 Diagonal,
42 Comet,
44 Column,
46 Scattered,
48}
49
50impl OrbVariant {
51 pub const ALL: [Self; 5] = [
53 Self::Radial,
54 Self::Diagonal,
55 Self::Comet,
56 Self::Column,
57 Self::Scattered,
58 ];
59
60 pub fn slug(self) -> &'static str {
62 match self {
63 Self::Radial => "radial",
64 Self::Diagonal => "diagonal",
65 Self::Comet => "comet",
66 Self::Column => "column",
67 Self::Scattered => "scattered",
68 }
69 }
70
71 fn cell_delay(self, x: usize, y: usize) -> i64 {
75 let mid = (N - 1) as f32 / 2.0;
76 let dx = x as f32 - mid;
77 let dy = y as f32 - mid;
78 match self {
79 Self::Radial => {
82 (dx.hypot(dy) * 700.0) as i64 - if dx == 0.0 && dy == 0.0 { 180 } else { 0 }
83 }
84 Self::Diagonal => ((x + y) as f32 / (2.0 * (N - 1) as f32) * 1500.0) as i64,
87 Self::Comet | Self::Scattered => {
88 let Some(ring_index) = perimeter_index(x, y) else {
89 return 0;
90 };
91 let count = perimeter_len() as f32;
92 match self {
93 Self::Comet => {
94 -(((perimeter_len() - ring_index) % perimeter_len()) as f32 / count
95 * CYCLE_MS as f32) as i64
96 }
97 Self::Scattered => {
99 -(((ring_index * 3) % perimeter_len()) as f32 / count * CYCLE_MS as f32)
100 as i64
101 }
102 _ => unreachable!("outer match covers both arms"),
103 }
104 }
105 Self::Column => (x as f32 / (N - 1) as f32 * 1100.0) as i64,
106 }
107 }
108}
109
110fn perimeter_index(x: usize, y: usize) -> Option<usize> {
114 let last = N - 1;
115 let on_ring = x == 0 || x == last || y == 0 || y == last;
116 if !on_ring {
117 return None;
118 }
119 let index = if y == 0 {
122 x
123 } else if x == last {
124 last + y
125 } else if y == last {
126 last + last + (last - x)
127 } else {
128 last + last + last + (last - y)
129 };
130 Some(index)
131}
132
133fn perimeter_len() -> usize {
135 4 * (N - 1)
136}
137
138#[derive(IntoElement)]
156pub struct Orbs {
157 id: ElementId,
158 style: StyleRefinement,
159 diameter: Pixels,
160 variant: OrbVariant,
161}
162
163impl Orbs {
164 #[track_caller]
166 pub fn new() -> Self {
167 Self {
168 id: ElementId::CodeLocation(*std::panic::Location::caller()),
169 style: StyleRefinement::default(),
170 diameter: px(40.),
171 variant: OrbVariant::default(),
172 }
173 }
174
175 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
178 self.id = id.into();
179 self
180 }
181
182 pub fn diameter(mut self, diameter: impl Into<Pixels>) -> Self {
184 self.diameter = diameter.into();
185 self
186 }
187
188 pub fn variant(mut self, variant: OrbVariant) -> Self {
190 self.variant = variant;
191 self
192 }
193}
194
195impl Default for Orbs {
196 #[track_caller]
197 fn default() -> Self {
198 Self::new()
199 }
200}
201
202impl Styled for Orbs {
203 fn style(&mut self) -> &mut StyleRefinement {
204 &mut self.style
205 }
206}
207
208struct LatticeDot {
214 home_x: Pixels,
215 home_y: Pixels,
216 gather: (f32, f32),
217 release: (f32, f32),
218 seeded_phase: f32,
219 color: gpui::Hsla,
220}
221
222impl RenderOnce for Orbs {
223 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
224 let tokens = cx.theme().semantic_tokens();
225 let full = crate::motion::motion_is_full(cx);
226 let lattice = MotionTokens::read(cx).orb_lattice();
227 let scale = self.diameter.as_f32() / STAGE;
228 let dot = px(4.2 * scale);
229 let pitch = px(6.5 * scale);
231 let colors = [cx.theme().primary, cx.theme().info, cx.theme().cyan];
232 let variant = self.variant;
233 let variant_index = OrbVariant::ALL
234 .iter()
235 .position(|candidate| *candidate == variant)
236 .unwrap_or_default() as u64;
237 let span = px((N - 1) as f32 * pitch.as_f32());
241 let origin = (self.diameter - span - dot) / 2.0;
242 let radius = tokens.radius.full;
243
244 let dots: Vec<LatticeDot> = (0..N)
245 .flat_map(|y| (0..N).map(move |x| (x, y)))
246 .map(|(x, y)| {
247 let delay = variant.cell_delay(x, y);
248 let seeded_phase = if delay < 0 {
252 (CYCLE_MS as i64 + delay % CYCLE_MS as i64) as f32 / CYCLE_MS as f32
253 } else {
254 delay as f32 / CYCLE_MS as f32
255 };
256 LatticeDot {
261 home_x: origin + pitch * x,
262 home_y: origin + pitch * y,
263 gather: swirl_offset(x, y, -SWIRL_RADIANS, scale),
264 release: swirl_offset(x, y, SWIRL_RADIANS, scale),
265 seeded_phase,
266 color: colors[(x + y) % colors.len()].opacity(0.55),
267 }
268 })
269 .collect();
270
271 div()
272 .id(self.id)
273 .relative()
274 .size(self.diameter)
275 .refine_style(&self.style)
276 .child(div().relative().size_full().with_visible_animation(
277 ElementId::NamedInteger("orb-lattice".into(), variant_index),
278 lattice.looping_synced(),
289 move |stage, delta| {
290 let delta = if full { delta } else { 0.0 };
291 stage.children(dots.iter().map(|spec| {
292 let phase = (delta + spec.seeded_phase) % 1.0;
293 let swing = if phase < 0.5 {
297 phase * 2.0
298 } else {
299 2.0 - phase * 2.0
300 };
301 let eased = swing * swing * (3.0 - 2.0 * swing);
302 let (ax, ay) = spec.gather;
303 let (bx, by) = spec.release;
304 let dx = ax + (bx - ax) * eased;
305 let dy = ay + (by - ay) * eased;
306 let swell = (phase * std::f32::consts::TAU).sin();
309 div()
310 .absolute()
311 .left(spec.home_x + px(dx))
312 .top(spec.home_y + px(dy))
313 .size(dot)
314 .rounded(radius)
315 .bg(spec.color)
316 .opacity(0.35 + 0.5 * (0.5 + 0.5 * swell))
317 }))
318 },
319 ))
320 }
321}
322
323const SWIRL_RADIANS: f32 = 1.05;
325
326const SWIRL_SPREAD: f32 = 1.6;
328
329fn swirl_offset(x: usize, y: usize, angle: f32, scale: f32) -> (f32, f32) {
332 let mid = (N - 1) as f32 / 2.0;
333 let dx = x as f32 - mid;
334 let dy = y as f32 - mid;
335 let pitch = PITCH_STAGE * scale;
336 let cos = angle.cos();
337 let sin = angle.sin();
338 (
339 ((dx * cos - dy * sin) * SWIRL_SPREAD - dx) * pitch,
340 ((dx * sin + dy * cos) * SWIRL_SPREAD - dy) * pitch,
341 )
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use gpui::{Context, Render, TestAppContext, WindowHandle, size};
348
349 struct LatticeProbe {
350 variant: OrbVariant,
351 }
352
353 impl Render for LatticeProbe {
354 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
355 div().child(Orbs::new().variant(self.variant))
356 }
357 }
358
359 fn open(variant: OrbVariant, cx: &mut TestAppContext) -> WindowHandle<LatticeProbe> {
360 cx.update(crate::init);
361 let window = cx.open_window(size(px(80.), px(80.)), |_, _| LatticeProbe { variant });
362 cx.run_until_parked();
363 next_frame(&window, cx);
364 window
365 }
366
367 fn next_frame(window: &WindowHandle<LatticeProbe>, cx: &mut TestAppContext) -> usize {
368 let callbacks = window
369 .update(cx, |_, window, cx| window.simulate_next_frame(cx))
370 .expect("the lattice window should remain open");
371 cx.run_until_parked();
372 callbacks
373 }
374
375 #[gpui::test]
376 fn every_variant_runs_its_lattice_on_one_clock(cx: &mut TestAppContext) {
377 let window = open(OrbVariant::Radial, cx);
378 for variant in OrbVariant::ALL {
379 window
380 .update(cx, |probe, _, cx| {
381 probe.variant = variant;
382 cx.notify();
383 })
384 .expect("the lattice window should remain open");
385 cx.run_until_parked();
386 next_frame(&window, cx);
389 assert_eq!(
390 next_frame(&window, cx),
391 1,
392 "{variant:?} must derive nine dots from one clock"
393 );
394 }
395 }
396
397 #[gpui::test]
398 fn reduced_motion_leaves_a_choreographed_still_lattice(cx: &mut TestAppContext) {
399 cx.update(|cx| cx.set_reduce_motion(true));
400 let window = open(OrbVariant::Radial, cx);
401 assert_eq!(
402 next_frame(&window, cx),
403 0,
404 "a held lattice schedules nothing; seeded phases are the still frame"
405 );
406 }
407
408 #[test]
409 fn radial_wavefront_radiates_from_center() {
410 let center = OrbVariant::Radial.cell_delay(1, 1);
411 let corner = OrbVariant::Radial.cell_delay(2, 2);
412 assert!(center < corner, "corner should lag the center");
413 assert_eq!(center, -180);
415 }
416
417 #[test]
418 fn diagonal_band_orders_by_x_plus_y() {
419 let first = OrbVariant::Diagonal.cell_delay(0, 0);
420 let middle = OrbVariant::Diagonal.cell_delay(1, 1);
421 let last = OrbVariant::Diagonal.cell_delay(2, 2);
422 assert!(first < middle && middle < last);
423 }
424
425 #[test]
426 fn column_travels_left_to_right() {
427 let left = OrbVariant::Column.cell_delay(0, 1);
428 let right = OrbVariant::Column.cell_delay(2, 1);
429 assert!(left < right);
430 }
431
432 #[test]
433 fn comet_walks_the_perimeter_and_skips_the_center() {
434 assert_eq!(OrbVariant::Comet.cell_delay(1, 1), 0);
435 let delays: Vec<i64> = [(0, 0), (1, 0), (2, 0), (2, 1), (2, 2)]
440 .map(|(x, y)| OrbVariant::Comet.cell_delay(x, y))
441 .to_vec();
442 assert_eq!(*delays.first().expect("non-empty"), 0);
443 assert!(
444 delays[1..].windows(2).all(|pair| pair[0] < pair[1]),
445 "follower delays must increase clockwise: {delays:?}"
446 );
447 }
448
449 #[test]
450 fn scattered_visits_every_ring_cell_with_shuffled_phases() {
451 let mut comet: Vec<u64> = perimeter_cells()
454 .map(|(x, y)| OrbVariant::Comet.cell_delay(x, y).unsigned_abs())
455 .collect();
456 let mut scattered: Vec<u64> = perimeter_cells()
457 .map(|(x, y)| OrbVariant::Scattered.cell_delay(x, y).unsigned_abs())
458 .collect();
459 comet.sort_unstable();
460 scattered.sort_unstable();
461 assert_eq!(comet, scattered);
462
463 let different = perimeter_cells()
466 .zip(perimeter_cells())
467 .any(|((cx_, cy_), (sx, sy))| {
468 OrbVariant::Comet.cell_delay(cx_, cy_) != OrbVariant::Scattered.cell_delay(sx, sy)
469 });
470 assert!(different, "scattered must not preserve the clockwise order");
471 }
472
473 fn perimeter_cells() -> impl Iterator<Item = (usize, usize)> {
475 (0..N)
476 .map(|x| (x, 0))
477 .chain((1..N).map(move |y| (N - 1, y)))
478 .chain((0..N - 1).rev().map(move |x| (x, N - 1)))
479 .chain((1..N - 1).rev().map(move |y| (0, y)))
480 }
481
482 #[test]
483 fn perimeter_covers_all_eight_ring_cells_exactly_once() {
484 let mut seen = Vec::new();
485 for y in 0..N {
486 for x in 0..N {
487 if let Some(index) = perimeter_index(x, y) {
488 seen.push(index);
489 }
490 }
491 }
492 seen.sort_unstable();
493 seen.dedup();
494 assert_eq!(seen.len(), perimeter_len());
495 assert_eq!(seen, (0..perimeter_len()).collect::<Vec<_>>());
496 }
497}