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