Skip to main content

gpui_kit_material/shapes/
element.rs

1//! Painting shapes: GPUI paths from outlines, and the [`MaterialShape`]
2//! element.
3//!
4//! Outlines go to GPUI as cubic curves, not polylines: they are what the shape
5//! is, the tessellator flattens them to its tolerance at any size, and an
6//! outline of 20-100 curves needs far fewer path events than a polyline as
7//! smooth would.
8use super::{
9    catalog::ShapeKind,
10    morph::Morph,
11    polygon::{Cubic, RoundedPolygon},
12};
13use crate::{
14    color::ColorRole,
15    motion::{self, MotionToken},
16    theme::color,
17    tokens::typography::rem,
18};
19use gpui::{prelude::*, *};
20
21/// A shape's default size in design px: the kit's 48dp target.
22pub const DEFAULT_SIZE: f32 = 48.;
23
24/// A built path, reused while its key (what it draws, its size and origin) is
25/// unchanged. The element that holds it, through keyed state, owns it, and a
26/// changed key is the only invalidation. A key is kept only once seen twice in
27/// a row, so a running morph, whose key changes every frame, stores nothing.
28pub(crate) struct PathCache<K> {
29    key: Option<K>,
30    path: Option<Path<Pixels>>,
31}
32
33impl<K> Default for PathCache<K> {
34    fn default() -> Self {
35        Self {
36            key: None,
37            path: None,
38        }
39    }
40}
41
42impl<K: PartialEq> PathCache<K> {
43    /// The path for `key`, from `build` unless it is already held.
44    pub(crate) fn path(
45        &mut self,
46        key: K,
47        build: impl FnOnce() -> Option<Path<Pixels>>,
48    ) -> Option<Path<Pixels>> {
49        if self.key.as_ref() != Some(&key) {
50            self.key = Some(key);
51            self.path = None;
52            return build();
53        }
54        if self.path.is_none() {
55            self.path = build();
56        }
57        self.path.clone()
58    }
59}
60
61/// Build the path, with the outline's control-point box centered on `center`
62/// (Compose's `processPath`), `side` px per outline unit, turned clockwise.
63fn outline_path(
64    each: impl Fn(&mut dyn FnMut(&Cubic)),
65    center: Point<Pixels>,
66    side: f32,
67    rotation_degrees: f32,
68) -> Option<Path<Pixels>> {
69    let mut bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
70    each(&mut |cubic| {
71        for [x, y] in cubic.points.as_chunks::<2>().0 {
72            bounds[0] = bounds[0].min(*x);
73            bounds[1] = bounds[1].min(*y);
74            bounds[2] = bounds[2].max(*x);
75            bounds[3] = bounds[3].max(*y);
76        }
77    });
78    let (middle_x, middle_y) = ((bounds[0] + bounds[2]) / 2., (bounds[1] + bounds[3]) / 2.);
79    let (sin, cos) = rotation_degrees.to_radians().sin_cos();
80    let at = |x: f32, y: f32| {
81        let (x, y) = ((x - middle_x) * side, (y - middle_y) * side);
82        point(
83            center.x + px(x * cos - y * sin),
84            center.y + px(x * sin + y * cos),
85        )
86    };
87    let mut path = PathBuilder::fill();
88    let mut first = true;
89    each(&mut |cubic| {
90        let [a0x, a0y, c0x, c0y, c1x, c1y, a1x, a1y] = cubic.points;
91        if first {
92            path.move_to(at(a0x, a0y));
93            first = false;
94        }
95        path.cubic_bezier_to(at(a1x, a1y), at(c0x, c0y), at(c1x, c1y));
96    });
97    if first {
98        return None;
99    }
100    path.close();
101    path.build().ok()
102}
103
104/// The path of `polygon`: see [`morph_path`].
105pub fn polygon_path(
106    polygon: &RoundedPolygon,
107    center: Point<Pixels>,
108    side: f32,
109    rotation_degrees: f32,
110) -> Option<Path<Pixels>> {
111    outline_path(
112        |visit| polygon.cubics().iter().for_each(&mut *visit),
113        center,
114        side,
115        rotation_degrees,
116    )
117}
118
119/// The path of `morph` at `progress`, centered on `center`, `side` px per
120/// outline unit (a Material shape's full size, as they are 0-1), and turned
121/// clockwise about its center.
122pub fn morph_path(
123    morph: &Morph,
124    progress: f32,
125    center: Point<Pixels>,
126    side: f32,
127    rotation_degrees: f32,
128) -> Option<Path<Pixels>> {
129    outline_path(
130        |visit| morph.for_each_cubic(progress, &mut *visit),
131        center,
132        side,
133        rotation_degrees,
134    )
135}
136
137/// What a [`MaterialShape`] draws: where it is between two shapes, and its
138/// turn.
139#[derive(Clone, Copy, Debug, PartialEq)]
140pub struct ShapeFrame {
141    /// The shape at 0.
142    pub from: ShapeKind,
143    /// The shape at 1.
144    pub to: ShapeKind,
145    /// 0 at `from`, 1 at `to`. Values beyond extrapolate, for overshoot.
146    pub progress: f32,
147    /// Clockwise turn about the shape's center.
148    pub rotation_degrees: f32,
149}
150
151impl ShapeFrame {
152    /// A shape at rest, unrotated.
153    pub const fn still(shape: ShapeKind) -> Self {
154        Self::morph(shape, shape, 1.)
155    }
156
157    /// `progress` of the way from `from` to `to`, unrotated. A non-finite
158    /// progress rests at `from`.
159    pub const fn morph(from: ShapeKind, to: ShapeKind, progress: f32) -> Self {
160        Self {
161            from,
162            to,
163            progress: if progress.is_finite() { progress } else { 0. },
164            rotation_degrees: 0.,
165        }
166    }
167
168    /// The frame's path in a square of `side` px around `center`.
169    pub fn path(self, center: Point<Pixels>, side: f32) -> Option<Path<Pixels>> {
170        if self.from == self.to {
171            polygon_path(self.to.polygon(), center, side, self.rotation_degrees)
172        } else {
173            morph_path(
174                self.from.morph_to(self.to),
175                self.progress,
176                center,
177                side,
178                self.rotation_degrees,
179            )
180        }
181    }
182}
183
184/// How many shape changes [`MaterialShape::animated`] remembers. A morph
185/// still running after this many newer changes jumps to the oldest kept.
186const HISTORY: usize = 8;
187
188/// The shapes an animated element has been given, newest at `generation`.
189struct ShapeHistory {
190    kinds: [ShapeKind; HISTORY],
191    generation: usize,
192}
193
194impl ShapeHistory {
195    fn new(kind: ShapeKind) -> Self {
196        Self {
197            kinds: [kind; HISTORY],
198            generation: 0,
199        }
200    }
201
202    fn current(&self) -> ShapeKind {
203        self.kinds[self.generation % HISTORY]
204    }
205
206    /// Record `kind`, returning the generation to animate to.
207    fn advance(&mut self, kind: ShapeKind) -> usize {
208        if self.current() != kind {
209            self.generation += 1;
210            self.kinds[self.generation % HISTORY] = kind;
211        }
212        self.generation
213    }
214
215    /// The frame at presentation position `shown`, in generations: a retarget
216    /// carries on through the shape it was heading for, so nothing jumps.
217    fn frame(&self, shown: f32) -> ShapeFrame {
218        if self.generation == 0 {
219            return ShapeFrame::still(self.current());
220        }
221        let oldest = self.generation.saturating_sub(HISTORY - 1);
222        let last = (shown.ceil().max(0.) as usize).clamp(oldest + 1, self.generation);
223        let progress = shown - (last - 1) as f32;
224        ShapeFrame::morph(
225            self.kinds[(last - 1) % HISTORY],
226            self.kinds[last % HISTORY],
227            // Older than the history: rest on the oldest kept shape.
228            if last == oldest + 1 {
229                progress.max(0.)
230            } else {
231                progress
232            },
233        )
234    }
235}
236
237/// The frame an animated shape draws this render, recording `kind`.
238fn animated_frame(
239    id: &ElementId,
240    kind: ShapeKind,
241    token: MotionToken,
242    window: &mut Window,
243    cx: &mut App,
244) -> ShapeFrame {
245    let history = window.use_keyed_state((id.clone(), "material-shape-history"), cx, |_, _| {
246        ShapeHistory::new(kind)
247    });
248    let generation = history.update(cx, |history, _| history.advance(kind));
249    let shown = motion::token_value(
250        (id.clone(), "material-shape-morph"),
251        generation as f32,
252        token,
253        window,
254        cx,
255    );
256    history.read(cx).frame(shown)
257}
258
259enum Source {
260    Frame(ShapeFrame),
261    Animated(ShapeKind),
262}
263
264/// A Material shape, or a morph between two, filled with a color role.
265///
266/// [`Self::new`] draws one shape; with [`Self::animated`] it springs to each
267/// new shape it is given, retargeting from where it is, and reduced motion
268/// shows the new shape at once. [`Self::frame`] draws what the caller's own
269/// clock says. Without [`Self::label`] the shape is decorative and is not in
270/// the accessibility tree.
271#[derive(gpui::IntoElement)]
272pub struct MaterialShape {
273    id: ElementId,
274    source: Source,
275    role: ColorRole,
276    size: f32,
277    rotation_degrees: f32,
278    label: Option<SharedString>,
279    token: MotionToken,
280}
281
282impl MaterialShape {
283    /// A `kind` shape: primary, 48 design px, unrotated, decorative.
284    pub fn new(id: impl Into<ElementId>, kind: ShapeKind) -> Self {
285        Self {
286            id: id.into(),
287            source: Source::Frame(ShapeFrame::still(kind)),
288            role: ColorRole::PRIMARY,
289            size: DEFAULT_SIZE,
290            rotation_degrees: 0.,
291            label: None,
292            token: MotionToken::DefaultSpatial,
293        }
294    }
295
296    /// Draw exactly `frame`, for a caller's own clock.
297    pub fn frame(mut self, frame: ShapeFrame) -> Self {
298        self.rotation_degrees = frame.rotation_degrees;
299        self.source = Source::Frame(frame);
300        self
301    }
302
303    /// Whether a new shape morphs from the last one on a spring, rather than
304    /// replacing it. The shape is the one given to [`Self::new`].
305    pub fn animated(mut self, animated: bool) -> Self {
306        let kind = match self.source {
307            Source::Frame(frame) => frame.to,
308            Source::Animated(kind) => kind,
309        };
310        self.source = if animated {
311            Source::Animated(kind)
312        } else {
313            Source::Frame(ShapeFrame::still(kind))
314        };
315        self
316    }
317
318    /// The motion token [`Self::animated`] follows. Default spatial by
319    /// default.
320    pub fn motion(mut self, token: MotionToken) -> Self {
321        self.token = token;
322        self
323    }
324
325    /// The fill.
326    pub fn color(mut self, role: ColorRole) -> Self {
327        self.role = role;
328        self
329    }
330
331    /// The square's side in design px, following the text scale. Ignored
332    /// unless finite and positive.
333    pub fn size(mut self, size: f32) -> Self {
334        if size.is_finite() && size > 0. {
335            self.size = size;
336        }
337        self
338    }
339
340    /// A clockwise turn about the center. A non-finite turn is ignored.
341    pub fn rotation(mut self, degrees: f32) -> Self {
342        if degrees.is_finite() {
343            self.rotation_degrees = degrees;
344        }
345        self
346    }
347
348    /// The accessible name, exposing the shape as an image.
349    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
350        self.label = Some(label.into());
351        self
352    }
353}
354
355impl RenderOnce for MaterialShape {
356    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
357        let mut frame = match self.source {
358            Source::Frame(frame) => frame,
359            Source::Animated(kind) => animated_frame(&self.id, kind, self.token, window, cx),
360        };
361        frame.rotation_degrees = self.rotation_degrees;
362        let ink = color(self.role);
363        let paths = window.use_keyed_state((self.id.clone(), "material-shape-path"), cx, |_, _| {
364            PathCache::<(ShapeFrame, f32, Point<Pixels>)>::default()
365        });
366        div()
367            .id(self.id)
368            .flex_shrink_0()
369            .size(rem(self.size))
370            .when_some(self.label, |node, label| {
371                node.role(Role::Image).aria_label(label)
372            })
373            .child(
374                canvas(
375                    |_, _, _| {},
376                    move |bounds, _, window, cx| {
377                        let side = f32::from(bounds.size.width.min(bounds.size.height));
378                        let center = bounds.center();
379                        let path = paths.update(cx, |paths, _| {
380                            paths.path((frame, side, center), || frame.path(center, side))
381                        });
382                        if let Some(path) = path {
383                            window.paint_path(path, ink);
384                        }
385                    },
386                )
387                .size_full(),
388            )
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use core::prelude::v1::test;
396
397    #[test]
398    fn the_history_walks_through_each_shape_in_turn() {
399        let mut history = ShapeHistory::new(ShapeKind::Circle);
400        assert_eq!(history.frame(0.), ShapeFrame::still(ShapeKind::Circle));
401        assert_eq!(
402            history.advance(ShapeKind::Circle),
403            0,
404            "same shape: no morph"
405        );
406        assert_eq!(history.advance(ShapeKind::Heart), 1);
407        assert_eq!(history.advance(ShapeKind::Bun), 2);
408        // Interrupted a third of the way into the first morph.
409        let early = history.frame(0.33);
410        assert_eq!(
411            (early.from, early.to),
412            (ShapeKind::Circle, ShapeKind::Heart)
413        );
414        assert!((early.progress - 0.33).abs() < 1e-6);
415        // Past it, the second morph; the two meet with the same shape.
416        let late = history.frame(1.25);
417        assert_eq!((late.from, late.to), (ShapeKind::Heart, ShapeKind::Bun));
418        assert!((late.progress - 0.25).abs() < 1e-6);
419        // A spring's overshoot extrapolates the last morph.
420        let over = history.frame(2.1);
421        assert_eq!((over.from, over.to), (ShapeKind::Heart, ShapeKind::Bun));
422        assert!((over.progress - 1.1).abs() < 1e-5);
423        assert_eq!(history.frame(2.).progress, 1.);
424    }
425
426    #[test]
427    fn long_bursts_of_changes_stay_inside_the_history() {
428        let mut history = ShapeHistory::new(ShapeKind::Circle);
429        for i in 0..100 {
430            history.advance(ShapeKind::ALL[1 + i % 34]);
431        }
432        // A presentation far behind every kept shape rests on the oldest.
433        let stale = history.frame(0.);
434        assert_eq!(stale.progress, 0.);
435        assert_eq!(
436            stale.from,
437            history.kinds[(history.generation - 7) % HISTORY]
438        );
439        // NaN from a broken clock lands on a kept morph, not out of bounds.
440        assert!(history.frame(f32::NAN).progress.is_finite());
441    }
442
443    #[test]
444    fn frames_build_paths_and_refuse_bad_progress() {
445        let center = point(px(24.), px(24.));
446        for kind in ShapeKind::ALL {
447            assert!(
448                ShapeFrame::still(kind).path(center, 48.).is_some(),
449                "{kind:?}"
450            );
451        }
452        let frame = ShapeFrame::morph(ShapeKind::Circle, ShapeKind::Boom, f32::NAN);
453        assert_eq!(frame.progress, 0.);
454        assert!(frame.path(center, 48.).is_some());
455        // A zero-sized box is reachable mid-layout; it must simply not panic.
456        let _ = frame.path(center, 0.);
457    }
458
459    #[test]
460    fn the_path_cache_keeps_only_a_key_seen_twice_in_a_row() {
461        let mut cache = PathCache::<u32>::default();
462        let builds = std::cell::Cell::new(0);
463        let build = || {
464            builds.set(builds.get() + 1);
465            ShapeFrame::still(ShapeKind::Bun).path(point(px(24.), px(24.)), 48.)
466        };
467        // A running morph: every key new, nothing stored, built each time.
468        assert!(cache.path(1, build).is_some());
469        assert!(cache.path(2, build).is_some());
470        assert_eq!(builds.get(), 2);
471        assert!(cache.path.is_none());
472        // At rest: built once more, then reused.
473        assert!(cache.path(2, build).is_some());
474        assert!(cache.path(2, build).is_some());
475        assert!(cache.path(2, build).is_some());
476        assert_eq!(builds.get(), 3);
477        // A changed key invalidates it.
478        assert!(cache.path(3, build).is_some());
479        assert!(cache.path.is_none());
480        assert_eq!(builds.get(), 4);
481    }
482
483    struct Harness {
484        kind: ShapeKind,
485        drawn: Option<ShapeFrame>,
486    }
487
488    impl Render for Harness {
489        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
490            window.set_rem_size(px(14.));
491            self.drawn = Some(animated_frame(
492                &ElementId::from("probe"),
493                self.kind,
494                MotionToken::DefaultSpatial,
495                window,
496                cx,
497            ));
498            div()
499                .flex()
500                .items_start()
501                .child(
502                    div().debug_selector(|| "shape".into()).child(
503                        MaterialShape::new("shape", self.kind)
504                            .label("Favorite")
505                            .size(96.)
506                            .animated(true),
507                    ),
508                )
509                .child(MaterialShape::new("decorative", ShapeKind::Bun))
510        }
511    }
512
513    fn draw(cx: &mut VisualTestContext) {
514        cx.update(|window, cx| {
515            window.refresh();
516            window.draw(cx).clear(cx);
517        });
518    }
519
520    #[gpui::test]
521    fn a_shape_fills_its_rem_sized_square(cx: &mut gpui::TestAppContext) {
522        let (_, cx) = cx.add_window_view(|_, _| Harness {
523            kind: ShapeKind::Heart,
524            drawn: None,
525        });
526        draw(cx);
527        assert_eq!(
528            cx.debug_bounds("shape").expect("rendered").size,
529            size(px(96.), px(96.))
530        );
531    }
532
533    /// Draw a new shape and return the frame drawn just after the change.
534    fn change_shape(cx: &mut gpui::TestAppContext, reduced: bool) -> ShapeFrame {
535        cx.update(|cx| cx.set_reduce_motion(reduced));
536        let (view, cx) = cx.add_window_view(|_, _| Harness {
537            kind: ShapeKind::Heart,
538            drawn: None,
539        });
540        draw(cx);
541        let first = view.read_with(cx, |view, _| view.drawn).expect("drawn");
542        assert_eq!(
543            first,
544            ShapeFrame::still(ShapeKind::Heart),
545            "no morph on mount"
546        );
547        view.update(cx, |view, cx| {
548            view.kind = ShapeKind::Bun;
549            cx.notify();
550        });
551        draw(cx);
552        view.read_with(cx, |view, _| view.drawn).expect("drawn")
553    }
554
555    #[gpui::test]
556    fn a_new_shape_morphs_from_the_last_one(cx: &mut gpui::TestAppContext) {
557        let frame = change_shape(cx, false);
558        assert_eq!((frame.from, frame.to), (ShapeKind::Heart, ShapeKind::Bun));
559        assert!(frame.progress < 1., "still on its way: {}", frame.progress);
560    }
561
562    #[gpui::test]
563    fn reduced_motion_shows_a_new_shape_at_once(cx: &mut gpui::TestAppContext) {
564        let frame = change_shape(cx, true);
565        assert_eq!((frame.to, frame.progress), (ShapeKind::Bun, 1.));
566    }
567}