Skip to main content

waterui_shape/
lib.rs

1//! Shape system for `WaterUI` with HDR support.
2//!
3//! This module provides a trait-based system for defining shapes that can be used
4//! for clipping views and as filled views.
5//!
6//! Filled shapes are emitted as native `ResolvedShape` raw views so each backend
7//! renders paths with its own 2D engine. Morphing shapes stay GPU-backed.
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! use waterui::prelude::*;
13//! use waterui::shape::*;
14//!
15//! // Clip to a circle
16//! image("avatar.jpg").clip(Circle);
17//!
18//! // Fill a shape with HDR color
19//! Circle.fill(Color::red().with_headroom(0.5))
20//! ```
21
22extern crate alloc;
23
24use core::f32::consts::{FRAC_PI_2, PI, TAU};
25#[cfg(feature = "gpu")]
26use core::fmt;
27use core::time::Duration;
28#[cfg(feature = "gpu")]
29use num_traits::ToPrimitive;
30
31#[cfg(feature = "gpu")]
32use nami::Signal as _;
33use nami::{Computed, SignalExt as _, signal::IntoComputed};
34#[cfg(feature = "gpu")]
35use shaderloom::CompiledShader;
36#[cfg(feature = "gpu")]
37use waterui_core::reactive::watcher::BoxWatcherGuard;
38use waterui_core::{Environment, View, easing::EasingCurve, metadata::MetadataKey};
39use waterui_graphics::color::Color;
40#[cfg(feature = "gpu")]
41use waterui_graphics::{
42    GpuContext, GpuFrame, GpuSurface, GpuView, reactive_color::ReactiveColor,
43    single_bind_group_render_stages,
44};
45
46#[cfg(feature = "gpu")]
47const MORPH_SHADER: CompiledShader = include!(concat!(env!("OUT_DIR"), "/morph.rs"));
48
49// ============================================================================
50// PathCommand - The primitive operations for drawing paths
51// ============================================================================
52
53/// A single path command for drawing shapes.
54///
55/// All coordinates are normalized (0.0-1.0) and scale with view bounds.
56/// Native backends convert these to absolute coordinates based on view size.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum PathCommand {
59    /// Move to a position without drawing.
60    MoveTo {
61        /// X coordinate (normalized 0.0-1.0)
62        x: f32,
63        /// Y coordinate (normalized 0.0-1.0)
64        y: f32,
65    },
66
67    /// Draw a straight line to a position.
68    LineTo {
69        /// X coordinate (normalized 0.0-1.0)
70        x: f32,
71        /// Y coordinate (normalized 0.0-1.0)
72        y: f32,
73    },
74
75    /// Draw a quadratic bezier curve.
76    QuadTo {
77        /// Control point x
78        cx: f32,
79        /// Control point y
80        cy: f32,
81        /// End point x
82        x: f32,
83        /// End point y
84        y: f32,
85    },
86
87    /// Draw a cubic bezier curve.
88    CubicTo {
89        /// First control point x
90        c1x: f32,
91        /// First control point y
92        c1y: f32,
93        /// Second control point x
94        c2x: f32,
95        /// Second control point y
96        c2y: f32,
97        /// End point x
98        x: f32,
99        /// End point y
100        y: f32,
101    },
102
103    /// Draw an arc.
104    Arc {
105        /// Center x (normalized)
106        cx: f32,
107        /// Center y (normalized)
108        cy: f32,
109        /// Radius x (normalized, relative to width)
110        rx: f32,
111        /// Radius y (normalized, relative to height)
112        ry: f32,
113        /// Start angle in radians
114        start: f32,
115        /// Sweep angle in radians (positive = clockwise)
116        sweep: f32,
117    },
118
119    /// Close the current subpath by drawing a line to the start.
120    Close,
121}
122
123#[inline]
124const fn clamp_radius(value: f32) -> f32 {
125    if value.is_finite() {
126        value.clamp(0.0, 0.5)
127    } else {
128        0.0
129    }
130}
131
132#[derive(Debug, Clone, Copy)]
133struct CornerRadii {
134    top_left: f32,
135    top_right: f32,
136    bottom_right: f32,
137    bottom_left: f32,
138}
139
140impl CornerRadii {
141    #[inline]
142    fn sanitized(mut self) -> Self {
143        self.top_left = clamp_radius(self.top_left);
144        self.top_right = clamp_radius(self.top_right);
145        self.bottom_right = clamp_radius(self.bottom_right);
146        self.bottom_left = clamp_radius(self.bottom_left);
147
148        // Prevent overlapping corner arcs (same behavior as CSS border-radius normalization).
149        let mut scale = 1.0f32;
150        let pairs = [
151            self.top_left + self.top_right,
152            self.bottom_left + self.bottom_right,
153            self.top_left + self.bottom_left,
154            self.top_right + self.bottom_right,
155        ];
156        for sum in pairs {
157            if sum > 1.0 {
158                scale = scale.min(1.0 / sum);
159            }
160        }
161        if scale < 1.0 {
162            self.top_left *= scale;
163            self.top_right *= scale;
164            self.bottom_right *= scale;
165            self.bottom_left *= scale;
166        }
167        self
168    }
169}
170
171// ============================================================================
172// Shape Trait
173// ============================================================================
174
175/// A trait for types that can produce path commands for clipping.
176///
177/// All coordinates are normalized (0.0-1.0) and scale with view bounds.
178/// Built-in shapes use stack-allocated arrays for zero heap allocation.
179pub trait Shape {
180    /// The iterator type returned by `path()`.
181    type Iter: IntoIterator<Item = PathCommand>;
182
183    /// Returns the path commands that define this shape.
184    fn path(&self) -> Self::Iter;
185
186    /// Returns what this shape *is*, for backends that can render it directly.
187    ///
188    /// Prefer this over [`Self::path`] wherever a backend can act on it. Path
189    /// commands are normalized per axis, so resolving them against a non-square
190    /// rect makes circular corners elliptical; the kind lets a backend resolve a
191    /// normalized radius against the shorter side instead. Defaults to
192    /// [`ShapeKind::CustomPath`], which means "only the path describes me".
193    fn shape_kind(&self) -> ShapeKind {
194        ShapeKind::CustomPath
195    }
196}
197
198// ============================================================================
199// Common Shape Implementations
200// ============================================================================
201
202/// A circle inscribed in the view bounds.
203#[derive(Debug, Clone, Copy, Default)]
204pub struct Circle;
205
206impl Shape for Circle {
207    type Iter = [PathCommand; 1];
208
209    fn path(&self) -> Self::Iter {
210        [PathCommand::Arc {
211            cx: 0.5,
212            cy: 0.5,
213            rx: 0.5,
214            ry: 0.5,
215            start: 0.0,
216            sweep: TAU,
217        }]
218    }
219
220    fn shape_kind(&self) -> ShapeKind {
221        ShapeKind::Circle
222    }
223}
224
225/// An ellipse that fills the view bounds.
226#[derive(Debug, Clone, Copy, Default)]
227pub struct Ellipse;
228
229impl Shape for Ellipse {
230    type Iter = [PathCommand; 1];
231
232    fn path(&self) -> Self::Iter {
233        [PathCommand::Arc {
234            cx: 0.5,
235            cy: 0.5,
236            rx: 0.5,
237            ry: 0.5,
238            start: 0.0,
239            sweep: TAU,
240        }]
241    }
242
243    fn shape_kind(&self) -> ShapeKind {
244        ShapeKind::Ellipse
245    }
246}
247
248/// A capsule (pill) shape.
249#[derive(Debug, Clone, Copy, Default)]
250pub struct Capsule;
251
252impl Shape for Capsule {
253    type Iter = [PathCommand; 4];
254
255    /// Unit-space approximation only — an ellipse inscribed in the box.
256    ///
257    /// A pill's caps are half its *shorter* side, which normalized per-axis
258    /// coordinates cannot express without knowing the aspect ratio. Backends
259    /// must render a capsule from [`ShapeKind::Capsule`], not from these
260    /// commands.
261    fn path(&self) -> Self::Iter {
262        [
263            PathCommand::MoveTo { x: 0.5, y: 0.0 },
264            PathCommand::Arc {
265                cx: 0.5,
266                cy: 0.5,
267                rx: 0.5,
268                ry: 0.5,
269                start: -FRAC_PI_2,
270                sweep: PI,
271            },
272            PathCommand::Arc {
273                cx: 0.5,
274                cy: 0.5,
275                rx: 0.5,
276                ry: 0.5,
277                start: FRAC_PI_2,
278                sweep: PI,
279            },
280            PathCommand::Close,
281        ]
282    }
283
284    fn shape_kind(&self) -> ShapeKind {
285        ShapeKind::Capsule
286    }
287}
288
289/// A rectangle with uniform corner radius.
290#[derive(Debug, Clone, Copy)]
291pub struct RoundedRectangle {
292    /// Corner radius (normalized, 0.0-0.5 range).
293    pub corner_radius: f32,
294}
295
296impl RoundedRectangle {
297    /// Creates a new rounded rectangle with the given corner radius.
298    ///
299    /// The radius is **normalized**, not a length: it is a fraction of the
300    /// shape's shorter side, so `0.5` is fully rounded and anything above that
301    /// saturates there. Passing a point value (`28.0` for a 56pt-tall row)
302    /// therefore lands on `0.5` rather than failing, which is only what was
303    /// intended when the shape happens to be that tall.
304    ///
305    /// Reach for [`Capsule`] when the intent is "fully rounded at whatever size
306    /// this ends up": it says so directly and cannot drift as the shape resizes.
307    #[must_use]
308    pub const fn new(corner_radius: f32) -> Self {
309        Self { corner_radius }
310    }
311}
312
313impl Shape for RoundedRectangle {
314    type Iter = [PathCommand; 10];
315
316    fn path(&self) -> Self::Iter {
317        let r = CornerRadii {
318            top_left: self.corner_radius,
319            top_right: self.corner_radius,
320            bottom_right: self.corner_radius,
321            bottom_left: self.corner_radius,
322        }
323        .sanitized()
324        .top_left;
325        [
326            PathCommand::MoveTo { x: r, y: 0.0 },
327            PathCommand::LineTo { x: 1.0 - r, y: 0.0 },
328            PathCommand::Arc {
329                cx: 1.0 - r,
330                cy: r,
331                rx: r,
332                ry: r,
333                start: -FRAC_PI_2,
334                sweep: FRAC_PI_2,
335            },
336            PathCommand::LineTo { x: 1.0, y: 1.0 - r },
337            PathCommand::Arc {
338                cx: 1.0 - r,
339                cy: 1.0 - r,
340                rx: r,
341                ry: r,
342                start: 0.0,
343                sweep: FRAC_PI_2,
344            },
345            PathCommand::LineTo { x: r, y: 1.0 },
346            PathCommand::Arc {
347                cx: r,
348                cy: 1.0 - r,
349                rx: r,
350                ry: r,
351                start: FRAC_PI_2,
352                sweep: FRAC_PI_2,
353            },
354            PathCommand::LineTo { x: 0.0, y: r },
355            PathCommand::Arc {
356                cx: r,
357                cy: r,
358                rx: r,
359                ry: r,
360                start: PI,
361                sweep: FRAC_PI_2,
362            },
363            PathCommand::Close,
364        ]
365    }
366
367    fn shape_kind(&self) -> ShapeKind {
368        let r = CornerRadii {
369            top_left: self.corner_radius,
370            top_right: self.corner_radius,
371            bottom_right: self.corner_radius,
372            bottom_left: self.corner_radius,
373        }
374        .sanitized()
375        .top_left;
376        ShapeKind::RoundedRect { corner_radius: r }
377    }
378}
379
380/// A rectangle with independent corner radii.
381#[derive(Debug, Clone, Copy)]
382pub struct UnevenRoundedRectangle {
383    /// Top-leading corner radius (normalized).
384    pub top_leading: f32,
385    /// Top-trailing corner radius (normalized).
386    pub top_trailing: f32,
387    /// Bottom-leading corner radius (normalized).
388    pub bottom_leading: f32,
389    /// Bottom-trailing corner radius (normalized).
390    pub bottom_trailing: f32,
391}
392
393impl UnevenRoundedRectangle {
394    /// Creates a new uneven rounded rectangle with independent corner radii.
395    #[must_use]
396    pub const fn new(
397        top_leading: f32,
398        top_trailing: f32,
399        bottom_leading: f32,
400        bottom_trailing: f32,
401    ) -> Self {
402        Self {
403            top_leading,
404            top_trailing,
405            bottom_leading,
406            bottom_trailing,
407        }
408    }
409}
410
411impl Shape for UnevenRoundedRectangle {
412    type Iter = [PathCommand; 10];
413
414    fn path(&self) -> Self::Iter {
415        let corners = CornerRadii {
416            top_left: self.top_leading,
417            top_right: self.top_trailing,
418            bottom_right: self.bottom_trailing,
419            bottom_left: self.bottom_leading,
420        }
421        .sanitized();
422        let tl = corners.top_left;
423        let tr = corners.top_right;
424        let bl = corners.bottom_left;
425        let br = corners.bottom_right;
426        [
427            PathCommand::MoveTo { x: tl, y: 0.0 },
428            PathCommand::LineTo {
429                x: 1.0 - tr,
430                y: 0.0,
431            },
432            PathCommand::Arc {
433                cx: 1.0 - tr,
434                cy: tr,
435                rx: tr,
436                ry: tr,
437                start: -FRAC_PI_2,
438                sweep: FRAC_PI_2,
439            },
440            PathCommand::LineTo {
441                x: 1.0,
442                y: 1.0 - br,
443            },
444            PathCommand::Arc {
445                cx: 1.0 - br,
446                cy: 1.0 - br,
447                rx: br,
448                ry: br,
449                start: 0.0,
450                sweep: FRAC_PI_2,
451            },
452            PathCommand::LineTo { x: bl, y: 1.0 },
453            PathCommand::Arc {
454                cx: bl,
455                cy: 1.0 - bl,
456                rx: bl,
457                ry: bl,
458                start: FRAC_PI_2,
459                sweep: FRAC_PI_2,
460            },
461            PathCommand::LineTo { x: 0.0, y: tl },
462            PathCommand::Arc {
463                cx: tl,
464                cy: tl,
465                rx: tl,
466                ry: tl,
467                start: PI,
468                sweep: FRAC_PI_2,
469            },
470            PathCommand::Close,
471        ]
472    }
473
474    fn shape_kind(&self) -> ShapeKind {
475        let corners = CornerRadii {
476            top_left: self.top_leading,
477            top_right: self.top_trailing,
478            bottom_right: self.bottom_trailing,
479            bottom_left: self.bottom_leading,
480        }
481        .sanitized();
482        ShapeKind::UnevenRoundedRect {
483            top_left: corners.top_left,
484            top_right: corners.top_right,
485            bottom_left: corners.bottom_left,
486            bottom_right: corners.bottom_right,
487        }
488    }
489}
490
491/// A rectangle with a uniform corner radius in logical points.
492///
493/// [`RoundedRectangle`] expresses the corner as a fraction of the shorter side;
494/// this type expresses it as an absolute length — the shape specs give for a
495/// dialog (28dp) or a card (12dp). The rendered radius does not change as the
496/// shape resizes, which is the whole point: spec radii stay constant however
497/// tall or wide the surface ends up.
498#[derive(Debug, Clone, Copy)]
499pub struct FixedRoundedRectangle {
500    /// Corner radius in logical points.
501    pub corner_radius: f32,
502}
503
504impl FixedRoundedRectangle {
505    /// Creates a rounded rectangle whose corners are `corner_radius` points.
506    ///
507    /// The radius is clamped to half the shorter side when the shape resolves
508    /// against its bounds — a radius wider than the surface degenerates to the
509    /// capsule, the same way a normalized `0.5` does.
510    #[must_use]
511    pub const fn new(corner_radius: f32) -> Self {
512        Self {
513            corner_radius: if corner_radius.is_finite() {
514                corner_radius.max(0.0)
515            } else {
516                0.0
517            },
518        }
519    }
520}
521
522impl Shape for FixedRoundedRectangle {
523    type Iter = [PathCommand; 10];
524
525    /// Unit-space approximation only — maximally rounded, like a stadium.
526    ///
527    /// An absolute radius cannot be expressed in normalized commands without
528    /// knowing the bounds. Backends must render this shape from
529    /// [`ShapeKind::FixedRoundedRect`], not from these commands.
530    fn path(&self) -> Self::Iter {
531        RoundedRectangle::new(0.5).path()
532    }
533
534    fn shape_kind(&self) -> ShapeKind {
535        ShapeKind::FixedRoundedRect {
536            corner_radius: self.corner_radius,
537        }
538    }
539}
540
541/// A rectangle with independent corner radii in logical points.
542///
543/// The absolute-radius counterpart of [`UnevenRoundedRectangle`]: each corner
544/// is a length in points, used where a spec names per-corner values — a modal
545/// panel's trailing corners, say.
546#[derive(Debug, Clone, Copy)]
547pub struct FixedUnevenRoundedRectangle {
548    /// Top-leading corner radius in logical points.
549    pub top_leading: f32,
550    /// Top-trailing corner radius in logical points.
551    pub top_trailing: f32,
552    /// Bottom-leading corner radius in logical points.
553    pub bottom_leading: f32,
554    /// Bottom-trailing corner radius in logical points.
555    pub bottom_trailing: f32,
556}
557
558impl FixedUnevenRoundedRectangle {
559    /// Creates an uneven rounded rectangle with per-corner radii in points.
560    #[must_use]
561    pub const fn new(
562        top_leading: f32,
563        top_trailing: f32,
564        bottom_leading: f32,
565        bottom_trailing: f32,
566    ) -> Self {
567        const fn point_radius(radius: f32) -> f32 {
568            if radius.is_finite() {
569                radius.max(0.0)
570            } else {
571                0.0
572            }
573        }
574        Self {
575            top_leading: point_radius(top_leading),
576            top_trailing: point_radius(top_trailing),
577            bottom_leading: point_radius(bottom_leading),
578            bottom_trailing: point_radius(bottom_trailing),
579        }
580    }
581}
582
583impl Shape for FixedUnevenRoundedRectangle {
584    type Iter = [PathCommand; 10];
585
586    /// Unit-space approximation only — each corner saturates independently,
587    /// like [`UnevenRoundedRectangle`] at its maximum.
588    ///
589    /// Absolute radii cannot be expressed in normalized commands without
590    /// knowing the bounds. Backends must render this shape from
591    /// [`ShapeKind::FixedUnevenRoundedRect`], not from these commands.
592    fn path(&self) -> Self::Iter {
593        UnevenRoundedRectangle::new(
594            clamp_radius(self.top_leading),
595            clamp_radius(self.top_trailing),
596            clamp_radius(self.bottom_leading),
597            clamp_radius(self.bottom_trailing),
598        )
599        .path()
600    }
601
602    fn shape_kind(&self) -> ShapeKind {
603        ShapeKind::FixedUnevenRoundedRect {
604            top_left: self.top_leading,
605            top_right: self.top_trailing,
606            bottom_left: self.bottom_leading,
607            bottom_right: self.bottom_trailing,
608        }
609    }
610}
611
612/// A simple rectangle with sharp corners.
613#[derive(Debug, Clone, Copy, Default)]
614pub struct Rectangle;
615
616impl Shape for Rectangle {
617    type Iter = [PathCommand; 5];
618
619    fn path(&self) -> Self::Iter {
620        [
621            PathCommand::MoveTo { x: 0.0, y: 0.0 },
622            PathCommand::LineTo { x: 1.0, y: 0.0 },
623            PathCommand::LineTo { x: 1.0, y: 1.0 },
624            PathCommand::LineTo { x: 0.0, y: 1.0 },
625            PathCommand::Close,
626        ]
627    }
628
629    fn shape_kind(&self) -> ShapeKind {
630        ShapeKind::Rect
631    }
632}
633
634// ============================================================================
635// Custom Path Builder
636// ============================================================================
637
638/// A custom path defined by explicit commands.
639#[derive(Debug, Clone, Default)]
640pub struct Path {
641    commands: Vec<PathCommand>,
642}
643
644impl Path {
645    /// Creates a new empty path.
646    #[must_use]
647    pub fn new() -> Self {
648        Self::default()
649    }
650
651    /// Moves to a position without drawing.
652    #[must_use]
653    pub fn move_to(mut self, x: f32, y: f32) -> Self {
654        self.commands.push(PathCommand::MoveTo { x, y });
655        self
656    }
657
658    /// Draws a straight line to a position.
659    #[must_use]
660    pub fn line_to(mut self, x: f32, y: f32) -> Self {
661        self.commands.push(PathCommand::LineTo { x, y });
662        self
663    }
664
665    /// Draws a quadratic bezier curve.
666    #[must_use]
667    pub fn quad_to(mut self, cx: f32, cy: f32, x: f32, y: f32) -> Self {
668        self.commands.push(PathCommand::QuadTo { cx, cy, x, y });
669        self
670    }
671
672    /// Draws a cubic bezier curve.
673    #[must_use]
674    pub fn cubic_to(mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) -> Self {
675        self.commands.push(PathCommand::CubicTo {
676            c1x,
677            c1y,
678            c2x,
679            c2y,
680            x,
681            y,
682        });
683        self
684    }
685
686    /// Draws an arc.
687    #[must_use]
688    pub fn arc(mut self, cx: f32, cy: f32, rx: f32, ry: f32, start: f32, sweep: f32) -> Self {
689        self.commands.push(PathCommand::Arc {
690            cx,
691            cy,
692            rx,
693            ry,
694            start,
695            sweep,
696        });
697        self
698    }
699
700    /// Closes the current subpath.
701    #[must_use]
702    pub fn close(mut self) -> Self {
703        self.commands.push(PathCommand::Close);
704        self
705    }
706}
707
708impl Shape for Path {
709    type Iter = alloc::vec::IntoIter<PathCommand>;
710
711    fn path(&self) -> Self::Iter {
712        self.commands.clone().into_iter()
713    }
714
715    fn shape_kind(&self) -> ShapeKind {
716        ShapeKind::CustomPath
717    }
718}
719
720// ============================================================================
721// ClipShape Metadata
722// ============================================================================
723
724/// Metadata for clipping a view to a shape.
725///
726/// Carries both the structured [`ShapeKind`] and the unit-space path. Backends
727/// should prefer the kind: [`PathCommand`] coordinates are normalized per axis,
728/// so resolving them against a non-square rect turns a circular corner into an
729/// elliptical one — a fully-rounded clip comes out as an ellipse instead of a
730/// pill. The kind says what the shape *is*, letting a backend resolve a
731/// normalized radius against the shorter side the way [`FilledShape`] already
732/// does. The commands remain the fallback for [`ShapeKind::CustomPath`].
733#[derive(Debug)]
734pub struct ClipShape {
735    kind: ShapeKind,
736    commands: Vec<PathCommand>,
737}
738
739impl ClipShape {
740    /// Creates a new clip shape from any type implementing Shape.
741    #[allow(clippy::needless_pass_by_value)]
742    pub fn new(shape: impl Shape) -> Self {
743        Self {
744            kind: shape.shape_kind(),
745            commands: shape.path().into_iter().collect(),
746        }
747    }
748
749    /// Returns the structured shape kind. Prefer this over [`Self::commands`];
750    /// see the type documentation.
751    #[must_use]
752    pub const fn kind(&self) -> ShapeKind {
753        self.kind
754    }
755
756    /// Returns the unit-space path commands.
757    #[must_use]
758    pub fn commands(&self) -> &[PathCommand] {
759        &self.commands
760    }
761}
762
763impl MetadataKey for ClipShape {}
764
765// ============================================================================
766// ShapeKind - For backend rendering optimization
767// ============================================================================
768
769/// The kind of shape for backend rendering optimization.
770#[derive(Debug, Clone, Copy, Default)]
771pub enum ShapeKind {
772    /// Rectangle with sharp corners.
773    #[default]
774    Rect,
775    /// Circle inscribed in bounds.
776    Circle,
777    /// Ellipse filling bounds.
778    Ellipse,
779    /// Rectangle with uniform corner radius.
780    RoundedRect {
781        /// Corner radius (normalized 0.0-0.5).
782        corner_radius: f32,
783    },
784    /// Rectangle with per-corner radii.
785    UnevenRoundedRect {
786        /// Top-left corner radius.
787        top_left: f32,
788        /// Top-right corner radius.
789        top_right: f32,
790        /// Bottom-left corner radius.
791        bottom_left: f32,
792        /// Bottom-right corner radius.
793        bottom_right: f32,
794    },
795    /// Capsule (pill) shape.
796    Capsule,
797    /// Rectangle with a uniform corner radius in logical points.
798    ///
799    /// Unlike [`ShapeKind::RoundedRect`], the radius is an absolute length, not
800    /// a fraction of the shorter side: a `corner_radius` of `28.0` is 28 points
801    /// whether the bounds are 280x140 or 560x300. Backends clamp it to half the
802    /// shorter side at resolve time.
803    FixedRoundedRect {
804        /// Corner radius in logical points.
805        corner_radius: f32,
806    },
807    /// Rectangle with per-corner radii in logical points.
808    ///
809    /// Same absolute semantics as [`ShapeKind::FixedRoundedRect`], with each
810    /// corner named independently.
811    FixedUnevenRoundedRect {
812        /// Top-left corner radius in logical points.
813        top_left: f32,
814        /// Top-right corner radius in logical points.
815        top_right: f32,
816        /// Bottom-left corner radius in logical points.
817        bottom_left: f32,
818        /// Bottom-right corner radius in logical points.
819        bottom_right: f32,
820    },
821    /// Custom path.
822    CustomPath,
823}
824
825/// Resolved shape payload rendered directly by native backends.
826#[derive(Debug, Clone)]
827pub struct ResolvedShape {
828    /// Shape kind for backend-side optimization.
829    pub kind: ShapeKind,
830    /// Path commands in unit coordinate space.
831    pub commands: Vec<PathCommand>,
832    /// Environment-resolved fill color that remains reactive to theme changes.
833    pub fill: Computed<waterui_graphics::ResolvedColor>,
834}
835
836waterui_core::raw_view!(ResolvedShape, waterui_core::layout::StretchAxis::Both);
837
838/// Resolved morphing shape payload rendered directly by capable backends.
839#[derive(Debug, Clone)]
840pub struct ResolvedMorphShape {
841    /// Source shape kind.
842    pub from: ShapeKind,
843    /// Target shape kind.
844    pub to: ShapeKind,
845    /// Environment-resolved fill color that remains reactive to theme changes.
846    pub fill: Computed<waterui_graphics::ResolvedColor>,
847    /// Time-based morph animation configuration.
848    pub animation: MorphAnimation,
849    /// Optional explicit progress signal.
850    pub progress: Option<Computed<f32>>,
851}
852
853impl waterui_core::NativeView for ResolvedMorphShape {
854    fn stretch_axis(&self) -> waterui_core::layout::StretchAxis {
855        waterui_core::layout::StretchAxis::Both
856    }
857}
858
859// ============================================================================
860// FilledShape - Shape as a View with backend-native fill rendering
861// ============================================================================
862
863/// A shape filled with a color, resolved to `ResolvedShape`.
864#[derive(Debug)]
865pub struct FilledShape {
866    kind: ShapeKind,
867    commands: Vec<PathCommand>,
868    fill: Color,
869}
870
871impl FilledShape {
872    /// Creates a new filled shape from a shape and color.
873    #[allow(clippy::needless_pass_by_value)]
874    pub fn new(shape: impl Shape, fill: impl Into<Color>) -> Self {
875        Self {
876            kind: ShapeKind::CustomPath,
877            commands: shape.path().into_iter().collect(),
878            fill: fill.into(),
879        }
880    }
881
882    #[allow(clippy::needless_pass_by_value)]
883    fn with_kind(kind: ShapeKind, shape: impl Shape, fill: impl Into<Color>) -> Self {
884        Self {
885            kind,
886            commands: shape.path().into_iter().collect(),
887            fill: fill.into(),
888        }
889    }
890
891    /// Returns the path commands.
892    #[must_use]
893    pub fn commands(&self) -> &[PathCommand] {
894        &self.commands
895    }
896
897    /// Returns the fill color.
898    #[must_use]
899    pub const fn fill(&self) -> &Color {
900        &self.fill
901    }
902
903    /// Returns the shape kind.
904    #[must_use]
905    pub const fn kind(&self) -> ShapeKind {
906        self.kind
907    }
908
909    /// Creates a morphing shape animation from this shape to another built-in shape.
910    ///
911    /// Morphing currently supports SDF-backed built-in shapes:
912    /// `Rectangle`, `Circle`, `Ellipse`, `RoundedRectangle`, `UnevenRoundedRectangle`, `Capsule`.
913    #[must_use]
914    #[allow(clippy::needless_pass_by_value)]
915    pub fn morph_to(self, target: impl ShapeExt) -> MorphShape {
916        MorphShape::new(self.kind, target.shape_kind(), self.fill)
917    }
918}
919
920/// Configuration for shape morph animations.
921#[derive(Debug, Clone, Copy, PartialEq)]
922pub struct MorphAnimation {
923    /// Duration of one forward morph cycle.
924    pub duration: Duration,
925    /// Easing curve applied to normalized cycle progress.
926    pub easing: EasingCurve,
927    /// Whether the animation repeats after reaching the end.
928    pub repeat: bool,
929    /// Whether repeating animation should play in reverse every other cycle.
930    pub autoreverse: bool,
931}
932
933impl Default for MorphAnimation {
934    fn default() -> Self {
935        Self {
936            duration: Duration::from_millis(900),
937            easing: EasingCurve::EASE_IN_OUT,
938            repeat: true,
939            autoreverse: true,
940        }
941    }
942}
943
944impl MorphAnimation {
945    /// Creates a one-shot morph animation.
946    #[must_use]
947    pub const fn once(duration: Duration, easing: EasingCurve) -> Self {
948        Self {
949            duration,
950            easing,
951            repeat: false,
952            autoreverse: false,
953        }
954    }
955
956    #[cfg(feature = "gpu")]
957    #[must_use]
958    fn sample(self, elapsed: Duration) -> f32 {
959        if self.duration.is_zero() {
960            return 1.0;
961        }
962        let raw = elapsed.as_secs_f32() / self.duration.as_secs_f32();
963        let cycle = if self.repeat {
964            let base = raw.fract();
965            let index = raw
966                .floor()
967                .to_u64()
968                .expect("MorphAnimation::sample: cycle index must fit into u64");
969            if self.autoreverse && index % 2 == 1 {
970                1.0 - base
971            } else {
972                base
973            }
974        } else {
975            raw.clamp(0.0, 1.0)
976        };
977        self.easing.ease(cycle).clamp(0.0, 1.0)
978    }
979}
980
981/// A morphing filled shape view.
982#[derive(Debug, Clone)]
983pub struct MorphShape {
984    from: ShapeKind,
985    to: ShapeKind,
986    fill: Color,
987    animation: MorphAnimation,
988    progress: Option<Computed<f32>>,
989}
990
991impl MorphShape {
992    fn new(from: ShapeKind, to: ShapeKind, fill: Color) -> Self {
993        Self {
994            from,
995            to,
996            fill,
997            animation: MorphAnimation::default(),
998            progress: None,
999        }
1000    }
1001
1002    /// Sets explicit animation configuration.
1003    #[must_use]
1004    pub const fn animation(mut self, animation: MorphAnimation) -> Self {
1005        self.animation = animation;
1006        self
1007    }
1008
1009    /// Sets the cycle duration (keeps other animation options unchanged).
1010    #[must_use]
1011    pub const fn duration(mut self, duration: Duration) -> Self {
1012        self.animation.duration = duration;
1013        self
1014    }
1015
1016    /// Sets easing (keeps other animation options unchanged).
1017    #[must_use]
1018    pub const fn easing(mut self, easing: EasingCurve) -> Self {
1019        self.animation.easing = easing;
1020        self
1021    }
1022
1023    /// Enables/disables repeating.
1024    #[must_use]
1025    pub const fn repeat(mut self, repeat: bool) -> Self {
1026        self.animation.repeat = repeat;
1027        self
1028    }
1029
1030    /// Enables/disables autoreverse for repeating animations.
1031    #[must_use]
1032    pub const fn autoreverse(mut self, autoreverse: bool) -> Self {
1033        self.animation.autoreverse = autoreverse;
1034        self
1035    }
1036
1037    /// Overrides animated progress with an explicit reactive progress signal `[0, 1]`.
1038    ///
1039    /// When set, this takes precedence over the time-based animation config.
1040    #[must_use]
1041    pub fn progress(mut self, progress: impl IntoComputed<f32>) -> Self {
1042        self.progress = Some(progress.into_computed());
1043        self
1044    }
1045}
1046
1047impl View for FilledShape {
1048    fn body(self, env: &Environment) -> impl View {
1049        ResolvedShape {
1050            kind: self.kind,
1051            commands: self.commands,
1052            fill: self.fill.resolve(env).computed(),
1053        }
1054    }
1055
1056    /// Resolves to `ResolvedShape`, which fills both axes.
1057    fn stretch_axis(&self) -> waterui_core::layout::StretchAxis {
1058        waterui_core::layout::StretchAxis::Both
1059    }
1060}
1061
1062impl View for MorphShape {
1063    fn body(self, env: &Environment) -> impl View {
1064        let resolved = self.fill.resolve(env).computed();
1065        // The GPU fallback renderer also consumes `progress`, so clone it
1066        // only on that path; the lean path moves it into the native node.
1067        #[cfg(feature = "gpu")]
1068        let progress_for_gpu = self.progress.clone();
1069        let native = waterui_core::Native::new(ResolvedMorphShape {
1070            from: self.from,
1071            to: self.to,
1072            fill: resolved,
1073            animation: self.animation,
1074            progress: self.progress,
1075        });
1076        #[cfg(feature = "gpu")]
1077        let native = native.with_fallback(GpuSurface::new(MorphShapeRenderer::new(
1078            kind_to_morph_shape(self.from)
1079                .expect("morph source shape must be a built-in morphable shape"),
1080            kind_to_morph_shape(self.to)
1081                .expect("morph target shape must be a built-in morphable shape"),
1082            ReactiveColor::new(&Computed::constant(self.fill), env),
1083            self.animation,
1084            progress_for_gpu,
1085        )));
1086        native
1087    }
1088
1089    /// Resolves to `Native<ResolvedMorphShape>` (or its `GpuSurface`
1090    /// fallback), both of which fill both axes.
1091    fn stretch_axis(&self) -> waterui_core::layout::StretchAxis {
1092        waterui_core::layout::StretchAxis::Both
1093    }
1094}
1095
1096// ============================================================================
1097// MorphShapeRenderer - SDF morphing for built-in shapes
1098// ============================================================================
1099
1100#[cfg(feature = "gpu")]
1101#[derive(Debug, Clone, Copy)]
1102struct MorphSdfShape {
1103    shape_type: u32,
1104    radii: [f32; 4],
1105}
1106
1107#[cfg(feature = "gpu")]
1108fn kind_to_morph_shape(kind: ShapeKind) -> Option<MorphSdfShape> {
1109    match kind {
1110        ShapeKind::Rect => Some(MorphSdfShape {
1111            shape_type: 0,
1112            radii: [0.0; 4],
1113        }),
1114        ShapeKind::Circle => Some(MorphSdfShape {
1115            shape_type: 1,
1116            radii: [0.0; 4],
1117        }),
1118        ShapeKind::Ellipse => Some(MorphSdfShape {
1119            shape_type: 2,
1120            radii: [0.0; 4],
1121        }),
1122        ShapeKind::RoundedRect { corner_radius } => Some(MorphSdfShape {
1123            shape_type: 3,
1124            radii: [clamp_radius(corner_radius); 4],
1125        }),
1126        ShapeKind::UnevenRoundedRect {
1127            top_left,
1128            top_right,
1129            bottom_left,
1130            bottom_right,
1131        } => {
1132            let corners = CornerRadii {
1133                top_left,
1134                top_right,
1135                bottom_right,
1136                bottom_left,
1137            }
1138            .sanitized();
1139            Some(MorphSdfShape {
1140                shape_type: 3,
1141                radii: [
1142                    corners.top_left,
1143                    corners.top_right,
1144                    corners.bottom_right,
1145                    corners.bottom_left,
1146                ],
1147            })
1148        }
1149        ShapeKind::Capsule => Some(MorphSdfShape {
1150            shape_type: 4,
1151            radii: [0.0; 4],
1152        }),
1153        // Absolute radii cannot be normalized for the SDF shader without
1154        // knowing the bounds the shape resolves against, and custom paths
1155        // carry no radius structure at all.
1156        ShapeKind::FixedRoundedRect { .. }
1157        | ShapeKind::FixedUnevenRoundedRect { .. }
1158        | ShapeKind::CustomPath => None,
1159    }
1160}
1161
1162#[cfg(feature = "gpu")]
1163#[repr(C)]
1164#[derive(Debug, Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
1165struct MorphUniforms {
1166    color: [f32; 4],
1167    dimensions_and_progress: [f32; 4], // width, height, progress, pad
1168    shape_types: [f32; 4],             // from_type, to_type, pad, pad
1169    from_radii: [f32; 4],              // tl, tr, br, bl
1170    to_radii: [f32; 4],                // tl, tr, br, bl
1171}
1172
1173#[cfg(feature = "gpu")]
1174struct MorphShapeRenderer {
1175    from: MorphSdfShape,
1176    to: MorphSdfShape,
1177    fill_color: ReactiveColor,
1178    animation: MorphAnimation,
1179    progress: Option<Computed<f32>>,
1180    progress_guard: Option<BoxWatcherGuard>,
1181    start: Option<Duration>,
1182    pipeline: Option<wgpu::RenderPipeline>,
1183    uniform_buffer: Option<wgpu::Buffer>,
1184    bind_group: Option<wgpu::BindGroup>,
1185    pipeline_format: Option<wgpu::TextureFormat>,
1186}
1187
1188#[cfg(feature = "gpu")]
1189impl fmt::Debug for MorphShapeRenderer {
1190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1191        f.debug_struct("MorphShapeRenderer")
1192            .field("from", &self.from)
1193            .field("to", &self.to)
1194            .finish_non_exhaustive()
1195    }
1196}
1197
1198#[cfg(feature = "gpu")]
1199impl MorphShapeRenderer {
1200    fn new(
1201        from: MorphSdfShape,
1202        to: MorphSdfShape,
1203        fill_color: ReactiveColor,
1204        animation: MorphAnimation,
1205        progress: Option<Computed<f32>>,
1206    ) -> Self {
1207        Self {
1208            from,
1209            to,
1210            fill_color,
1211            animation,
1212            progress,
1213            progress_guard: None,
1214            start: None,
1215            pipeline: None,
1216            uniform_buffer: None,
1217            bind_group: None,
1218            pipeline_format: None,
1219        }
1220    }
1221}
1222
1223#[cfg(feature = "gpu")]
1224impl GpuView for MorphShapeRenderer {
1225    fn setup(
1226        &mut self,
1227        ctx: &GpuContext<'_>,
1228        _env: &mut waterui_core::Environment,
1229    ) -> impl core::future::Future<Output = ()> {
1230        self.fill_color.install(&ctx.redraw_handle);
1231        if let Some(progress) = &self.progress {
1232            let redraw = ctx.redraw_handle.clone();
1233            self.progress_guard = Some(progress.watch(move |_| redraw.request_redraw()));
1234        }
1235
1236        let (vertex_shader, fragment_shader, bind_group_layout) = single_bind_group_render_stages(
1237            &MORPH_SHADER,
1238            ctx.device,
1239            "the morph shape shader",
1240            "vs_main",
1241            "fs_main",
1242        );
1243
1244        let uniform_buffer = ctx.device.create_buffer(&wgpu::BufferDescriptor {
1245            label: Some("Morph Shape Uniforms"),
1246            size: core::mem::size_of::<MorphUniforms>() as u64,
1247            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1248            mapped_at_creation: false,
1249        });
1250
1251        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
1252            label: Some("Morph Shape Bind Group"),
1253            layout: &bind_group_layout,
1254            entries: &[wgpu::BindGroupEntry {
1255                binding: 0,
1256                resource: uniform_buffer.as_entire_binding(),
1257            }],
1258        });
1259
1260        let pipeline_layout = ctx
1261            .device
1262            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1263                label: Some("Morph Shape Pipeline Layout"),
1264                bind_group_layouts: &[Some(&bind_group_layout)],
1265                immediate_size: 0,
1266            });
1267
1268        let blend = ctx.alpha_blend_state();
1269
1270        let pipeline = ctx
1271            .device
1272            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1273                label: Some("Morph Shape Pipeline"),
1274                layout: Some(&pipeline_layout),
1275                vertex: wgpu::VertexState {
1276                    module: vertex_shader.module(),
1277                    entry_point: Some(vertex_shader.entry_point()),
1278                    buffers: &[],
1279                    compilation_options: wgpu::PipelineCompilationOptions::default(),
1280                },
1281                fragment: Some(wgpu::FragmentState {
1282                    module: fragment_shader.module(),
1283                    entry_point: Some(fragment_shader.entry_point()),
1284                    targets: &[Some(wgpu::ColorTargetState {
1285                        format: ctx.surface_format,
1286                        blend,
1287                        write_mask: wgpu::ColorWrites::ALL,
1288                    })],
1289                    compilation_options: wgpu::PipelineCompilationOptions::default(),
1290                }),
1291                primitive: wgpu::PrimitiveState {
1292                    topology: wgpu::PrimitiveTopology::TriangleList,
1293                    ..Default::default()
1294                },
1295                depth_stencil: None,
1296                multisample: wgpu::MultisampleState::default(),
1297                multiview_mask: None,
1298                cache: None,
1299            });
1300
1301        self.pipeline = Some(pipeline);
1302        self.uniform_buffer = Some(uniform_buffer);
1303        self.bind_group = Some(bind_group);
1304        self.pipeline_format = Some(ctx.surface_format);
1305        self.start = None;
1306        core::future::ready(())
1307    }
1308
1309    fn render(&mut self, frame: &mut GpuFrame) {
1310        assert_eq!(
1311            self.pipeline_format,
1312            Some(frame.format),
1313            "MorphShape target format changed after setup"
1314        );
1315        let pipeline = self
1316            .pipeline
1317            .as_ref()
1318            .expect("MorphShape render called before setup");
1319        let uniform_buffer = self
1320            .uniform_buffer
1321            .as_ref()
1322            .expect("MorphShape render called before setup");
1323        let bind_group = self
1324            .bind_group
1325            .as_ref()
1326            .expect("MorphShape render called before setup");
1327
1328        // The frame clock is supplied by the backend, so it is monotonic on
1329        // every target — `std::time::Instant` does not exist on wasm32 — and
1330        // deterministic under preview/offscreen pumping.
1331        let start = *self.start.get_or_insert_with(|| frame.elapsed());
1332        let age = frame.elapsed().saturating_sub(start);
1333        let progress = if let Some(signal) = &self.progress {
1334            let value = signal.get();
1335            assert!(value.is_finite(), "MorphShape progress must be finite");
1336            value.clamp(0.0, 1.0)
1337        } else {
1338            self.animation.sample(age)
1339        };
1340
1341        let fill_color = self.fill_color.get();
1342        let [r, g, b] = fill_color.linear_with_headroom();
1343        let uniforms = MorphUniforms {
1344            color: [r, g, b, fill_color.opacity],
1345            dimensions_and_progress: [
1346                u32_to_f32(frame.width),
1347                u32_to_f32(frame.height),
1348                progress,
1349                0.0,
1350            ],
1351            shape_types: [
1352                u32_to_f32(self.from.shape_type),
1353                u32_to_f32(self.to.shape_type),
1354                0.0,
1355                0.0,
1356            ],
1357            from_radii: self.from.radii,
1358            to_radii: self.to.radii,
1359        };
1360        frame
1361            .queue
1362            .write_buffer(uniform_buffer, 0, bytemuck::bytes_of(&uniforms));
1363
1364        let mut encoder = frame
1365            .device
1366            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1367                label: Some("Morph Shape Encoder"),
1368            });
1369
1370        {
1371            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1372                label: Some("Morph Shape Render Pass"),
1373                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1374                    view: &frame.view,
1375                    depth_slice: None,
1376                    resolve_target: None,
1377                    ops: wgpu::Operations {
1378                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1379                        store: wgpu::StoreOp::Store,
1380                    },
1381                })],
1382                depth_stencil_attachment: None,
1383                timestamp_writes: None,
1384                occlusion_query_set: None,
1385                multiview_mask: None,
1386            });
1387
1388            render_pass.set_pipeline(pipeline);
1389            render_pass.set_bind_group(0, bind_group, &[]);
1390            render_pass.draw(0..6, 0..1);
1391        }
1392
1393        frame.queue.submit(core::iter::once(encoder.finish()));
1394
1395        // Request continuous redraw while animation is active
1396        let animation_active =
1397            self.progress.is_none() && (self.animation.repeat || age < self.animation.duration);
1398        if animation_active {
1399            frame.request_redraw();
1400        }
1401    }
1402}
1403
1404#[cfg(feature = "gpu")]
1405fn u32_to_f32(value: u32) -> f32 {
1406    value
1407        .to_f32()
1408        .expect("shape dimensions must be representable as f32")
1409}
1410
1411// ============================================================================
1412// ShapeExt - Extension trait for adding fill to shapes
1413// ============================================================================
1414
1415/// Extension trait for filling shapes with color.
1416pub trait ShapeExt: Shape + Sized {
1417    /// Fills the shape with the specified color.
1418    fn fill(self, color: impl Into<Color>) -> FilledShape {
1419        FilledShape::with_kind(self.shape_kind(), self, color)
1420    }
1421
1422    /// Creates a morphing filled shape from this shape to another built-in shape.
1423    ///
1424    /// Morphing currently supports SDF-backed built-in shapes:
1425    /// `Rectangle`, `Circle`, `Ellipse`, `RoundedRectangle`, `UnevenRoundedRectangle`, `Capsule`.
1426    fn morph_to(self, target: impl ShapeExt, fill: impl Into<Color>) -> MorphShape {
1427        MorphShape::new(self.shape_kind(), target.shape_kind(), fill.into())
1428    }
1429}
1430
1431impl ShapeExt for Circle {}
1432
1433impl ShapeExt for Ellipse {}
1434
1435impl ShapeExt for Capsule {}
1436
1437impl ShapeExt for Rectangle {}
1438
1439impl ShapeExt for RoundedRectangle {}
1440
1441impl ShapeExt for UnevenRoundedRectangle {}
1442
1443impl ShapeExt for FixedRoundedRectangle {}
1444
1445impl ShapeExt for FixedUnevenRoundedRectangle {}
1446
1447impl ShapeExt for Path {}
1448
1449#[cfg(test)]
1450mod tests {
1451    use super::*;
1452
1453    #[test]
1454    fn rounded_rectangle_radius_is_clamped() {
1455        let kind = RoundedRectangle::new(9.0).shape_kind();
1456        match kind {
1457            ShapeKind::RoundedRect { corner_radius } => {
1458                assert!((corner_radius - 0.5).abs() < 1e-6);
1459            }
1460            _ => panic!("unexpected kind"),
1461        }
1462    }
1463
1464    #[test]
1465    fn fixed_rounded_rectangle_carries_its_radius_in_points() {
1466        let kind = FixedRoundedRectangle::new(28.0).shape_kind();
1467        match kind {
1468            ShapeKind::FixedRoundedRect { corner_radius } => {
1469                assert!((corner_radius - 28.0).abs() < 1e-6);
1470            }
1471            _ => panic!("unexpected kind"),
1472        }
1473    }
1474
1475    #[test]
1476    fn fixed_uneven_rounded_rectangle_carries_each_corner_in_points() {
1477        let kind = FixedUnevenRoundedRectangle::new(0.0, 16.0, 0.0, 16.0).shape_kind();
1478        match kind {
1479            ShapeKind::FixedUnevenRoundedRect {
1480                top_left,
1481                top_right,
1482                bottom_left,
1483                bottom_right,
1484            } => {
1485                assert!((top_left - 0.0).abs() < 1e-6);
1486                assert!((top_right - 16.0).abs() < 1e-6);
1487                assert!((bottom_left - 0.0).abs() < 1e-6);
1488                assert!((bottom_right - 16.0).abs() < 1e-6);
1489            }
1490            _ => panic!("unexpected kind"),
1491        }
1492    }
1493
1494    #[test]
1495    fn fixed_radii_reject_negative_and_non_finite_values() {
1496        let kind = FixedRoundedRectangle::new(f32::NAN).shape_kind();
1497        match kind {
1498            ShapeKind::FixedRoundedRect { corner_radius } => {
1499                assert!((corner_radius - 0.0).abs() < 1e-6);
1500            }
1501            _ => panic!("unexpected kind"),
1502        }
1503        let kind = FixedRoundedRectangle::new(-4.0).shape_kind();
1504        match kind {
1505            ShapeKind::FixedRoundedRect { corner_radius } => {
1506                assert!((corner_radius - 0.0).abs() < 1e-6);
1507            }
1508            _ => panic!("unexpected kind"),
1509        }
1510    }
1511
1512    #[test]
1513    fn uneven_radii_are_normalized_when_edges_overlap() {
1514        let kind = UnevenRoundedRectangle::new(0.8, 0.8, 0.8, 0.8).shape_kind();
1515        match kind {
1516            ShapeKind::UnevenRoundedRect {
1517                top_left,
1518                top_right,
1519                bottom_left,
1520                bottom_right,
1521            } => {
1522                assert!((top_left - 0.5).abs() < 1e-6);
1523                assert!((top_right - 0.5).abs() < 1e-6);
1524                assert!((bottom_left - 0.5).abs() < 1e-6);
1525                assert!((bottom_right - 0.5).abs() < 1e-6);
1526            }
1527            _ => panic!("unexpected kind"),
1528        }
1529    }
1530
1531    #[cfg(feature = "gpu")]
1532    #[test]
1533    fn one_shot_animation_reaches_end() {
1534        let animation = MorphAnimation::once(Duration::from_millis(200), EasingCurve::LINEAR);
1535        assert!((animation.sample(Duration::ZERO) - 0.0).abs() < 1e-6);
1536        assert!((animation.sample(Duration::from_millis(100)) - 0.5).abs() < 1e-3);
1537        assert!((animation.sample(Duration::from_secs(1)) - 1.0).abs() < 1e-6);
1538    }
1539}