repose-core 0.31.24

Repose's core runtime, view model, signals, composition locals, and animation clock.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
use crate::units::{Dp, Px, Sp};
use crate::{
    BaselineShift, Brush, ClipOp, Color, DrawStyle, FontStyle, FontSynthesis, FontWeight, Modifier,
    Rect, TextAlign, TextDecoration, TextDirection, TextSpan, Transform, Vec2,
};
use std::{fmt::Formatter, sync::Arc};

/// The constraints that will be passed to a subcomposed child. Values are in
/// [`Dp`], matching the units used by `Modifier`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SubcomposeScope {
    pub min_width: Dp,
    pub max_width: Dp,
    pub min_height: Dp,
    pub max_height: Dp,
}

impl SubcomposeScope {
    /// A scope with no finite upper bounds. Values are density-independent
    /// pixels (`Dp`).
    pub const UNBOUNDED: Self = Self {
        min_width: Dp(0.0),
        max_width: Dp(f32::INFINITY),
        min_height: Dp(0.0),
        max_height: Dp(f32::INFINITY),
    };

    /// Construct a scope from raw min/max [`Dp`] values. Non-finite minimums
    /// and negative values are coerced to zero, a NaN maximum becomes
    /// unbounded, and inverted bounds collapse to the maximum.
    pub fn new(min_width: Dp, max_width: Dp, min_height: Dp, max_height: Dp) -> Self {
        fn minimum(value: f32) -> f32 {
            if value.is_nan() || value < 0.0 || !value.is_finite() {
                0.0
            } else {
                value
            }
        }
        fn maximum(value: f32) -> f32 {
            if value.is_nan() || value < 0.0 {
                if value.is_nan() { f32::INFINITY } else { 0.0 }
            } else {
                value
            }
        }
        let mut scope = Self {
            min_width: Dp(minimum(min_width.0)),
            max_width: Dp(maximum(max_width.0)),
            min_height: Dp(minimum(min_height.0)),
            max_height: Dp(maximum(max_height.0)),
        };
        if scope.min_width.0 > scope.max_width.0 {
            scope.min_width = scope.max_width;
        }
        if scope.min_height.0 > scope.max_height.0 {
            scope.min_height = scope.max_height;
        }
        scope
    }

    pub fn has_bounded_width(&self) -> bool {
        self.max_width.0.is_finite()
    }

    pub fn has_bounded_height(&self) -> bool {
        self.max_height.0.is_finite()
    }
}

/// Scope passed to [`BoxWithConstraints`](crate::prelude::BoxWithConstraints)
/// content. All values are in [`Dp`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BoxWithConstraintsScope {
    pub min_width: Dp,
    pub max_width: Dp,
    pub min_height: Dp,
    pub max_height: Dp,
}

impl BoxWithConstraintsScope {
    /// `true` if the width is bounded by the parent (i.e. not infinite).
    pub fn has_bounded_width(&self) -> bool {
        self.max_width.0.is_finite()
    }

    /// `true` if the height is bounded by the parent (i.e. not infinite).
    pub fn has_bounded_height(&self) -> bool {
        self.max_height.0.is_finite()
    }
}

pub type ViewId = u64;

pub type ImageHandle = u64;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ImageFilter {
    #[default]
    Linear,
    Nearest,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct ImageSourceRect {
    pub x: u32,
    pub y: u32,
    pub width: u32,
    pub height: u32,
}

impl ImageSourceRect {
    pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
        Self {
            x,
            y,
            width,
            height,
        }
    }

    pub const fn is_empty(self) -> bool {
        self.width == 0 || self.height == 0
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ImageFit {
    /// ContentScale.Fit - default in Compose Image
    #[default]
    Contain,
    /// ContentScale.Crop
    Cover,
    /// ContentScale.FillWidth
    FitWidth,
    /// ContentScale.FillHeight
    FitHeight,
    /// ContentScale.FillBounds - stretch, ignore aspect
    FillBounds,
    /// ContentScale.Inside - like Contain but never upscales
    Inside,
    /// ContentScale.None - no scaling; placement comes from [`ImageAlignment`]
    None,
    /// Repeat the source to fill the bounds (Godot `STRETCH_SCALE` + tile).
    /// Unlike every other mode this ignores aspect ratio and may draw past a
    /// `source_rect` edge, wrapping instead of clipping.
    Tile,
}

/// Placement of scaled content inside the view bounds. Mirrors Godot's
/// `AspectRatioContainer::AlignmentMode` and covers every fixed `Alignment` in
/// Compose (`Begin`/`End` are physical left/right and top/bottom, not
/// direction-aware).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ImageAlignment {
    /// Alignment.Center
    #[default]
    Center,
    /// Alignment.TopLeft / BottomStart - left and top
    Begin,
    /// Alignment.TopRight / BottomEnd - right and bottom
    End,
}

impl ImageFit {
    /// Per-axis scale factors for drawing a `src`-sized source into `dst`.
    /// Mirrors Compose's `ContentScale.computeScaleFactor`, which layout and
    /// paint both need: layout sizes to it, paint draws with it.
    pub fn scale_factor(self, src: (f32, f32), dst: (f32, f32)) -> (f32, f32) {
        let fill_w = dst.0 / src.0;
        let fill_h = dst.1 / src.1;
        let uniform = |v: f32| (v, v);
        match self {
            Self::Contain => uniform(fill_w.min(fill_h)),
            Self::Cover => uniform(fill_w.max(fill_h)),
            Self::FitWidth => uniform(fill_w),
            Self::FitHeight => uniform(fill_h),
            Self::FillBounds => (fill_w, fill_h),
            Self::Inside => {
                if src.0 <= dst.0 && src.1 <= dst.1 {
                    uniform(1.0)
                } else {
                    uniform(fill_w.min(fill_h))
                }
            }
            Self::None | Self::Tile => uniform(1.0),
        }
    }
}

#[derive(Clone)]
pub struct OverlayEntry {
    pub id: u64,
    pub view: Box<View>,
}

#[derive(Clone)]
#[non_exhaustive]
pub enum ViewKind {
    Box,
    Row,
    Column,
    ZStack,
    OverlayHost,
    Text {
        text: String,
        color: Color,
        font_size: Sp,
        soft_wrap: bool,
        max_lines: Option<usize>,
        overflow: TextOverflow,
        font_family: Option<&'static str>,
        annotations: Option<Arc<[TextSpan]>>,
        text_align: TextAlign,
        font_weight: FontWeight,
        font_style: FontStyle,
        text_decoration: TextDecoration,
        letter_spacing: Sp,
        line_height: Sp,
        /// URL for clickable link text.
        url: Option<Arc<str>>,
        /// OpenType font variation settings (e.g. "wght 700, opsz 24").
        font_variation_settings: Option<Arc<str>>,
        /// Fill, outline, or both (faux-bold). Default `Fill`.
        draw_style: DrawStyle,
    },

    Image {
        handle: ImageHandle,
        tint: Color,
        fit: ImageFit,
        filter: ImageFilter,
        source_rect: Option<ImageSourceRect>,
        alignment: ImageAlignment,
    },
    /// A layout whose children are produced by calling `content` with the
    /// current `SubcomposeScope`. The closure is invoked during reconciliation
    /// and returns a list of `(slot_id, view)` pairs. Each slot id is a stable
    /// identity used to reconcile the returned view across frames. This is
    /// the building block for `BoxWithConstraints` and other
    /// constraints-driven layouts.
    ///
    /// Note: any `Modifier::key` set on a returned view is overwritten by its
    /// slot id so the slot's identity is stable across frames.
    SubcomposeLayout {
        content: Arc<dyn Fn(&SubcomposeScope) -> Vec<(u64, View)>>,
    },
}

impl std::fmt::Debug for ViewKind {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Box => f.write_str("Box"),
            Self::Row => f.write_str("Row"),
            Self::Column => f.write_str("Column"),
            Self::ZStack => f.write_str("ZStack"),
            Self::OverlayHost => f.write_str("OverlayHost"),

            Self::Image {
                handle,
                tint,
                fit,
                filter,
                source_rect,
                alignment,
            } => f
                .debug_struct("Image")
                .field("handle", handle)
                .field("tint", tint)
                .field("fit", fit)
                .field("filter", filter)
                .field("source_rect", source_rect)
                .field("alignment", alignment)
                .finish(),
            Self::SubcomposeLayout { .. } => f.write_str("SubcomposeLayout"),
            Self::Text { text, .. } => write!(f, "Text({:?})", text),
        }
    }
}

#[derive(Clone, Debug)]
pub struct View {
    pub id: ViewId,
    pub kind: ViewKind,
    pub modifier: Modifier,
    pub children: Vec<View>,
    pub semantics: Option<crate::semantics::Semantics>,
    /// Set by `scope!` macro to mark this as a scope boundary node.
    /// Carries the scope key (e.g., "title", "color_buttons") for per-scope
    /// TaffyTree isolation.
    pub scope_key: Option<String>,
}

impl View {
    pub fn new(id: ViewId, kind: ViewKind) -> Self {
        View {
            id,
            kind,
            modifier: Modifier::default(),
            children: vec![],
            semantics: None,
            scope_key: None,
        }
    }
    pub fn modifier(mut self, m: Modifier) -> Self {
        self.modifier = m;
        self
    }
    /// Mark this view as disabled - ignores pointer events.
    pub fn disabled(mut self) -> Self {
        self.modifier.disabled = true;
        self
    }
    pub fn with_children(mut self, kids: Vec<View>) -> Self {
        self.children = kids;
        self
    }
    pub fn children(mut self, kids: impl Into<Vec<View>>) -> Self {
        self.children = kids.into();
        self
    }
    pub fn semantics(mut self, s: crate::semantics::Semantics) -> Self {
        self.semantics = Some(s);
        self
    }
}

/// Renderable scene
#[derive(Clone, Debug, Default)]
pub struct Scene {
    pub clear_color: Color,
    pub nodes: Vec<SceneNode>,
}

/// Tunable text properties bundled for ergonomic [`Default`], so a
/// [`SceneNode::Text`] stays `{ rect, text, color, size, style }` and simple
/// nodes don't spell out every knob. `color` and `size` stay on the variant
/// because a default for either would silently draw invisible text.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct TextPaintStyle {
    pub font_family: Option<&'static str>,
    pub text_align: TextAlign,
    pub font_weight: FontWeight,
    pub font_style: FontStyle,
    pub text_decoration: TextDecoration,
    pub letter_spacing: Px,
    pub line_height: Px,
    pub text_direction: TextDirection,
    /// Whether the font renderer may synthesize bold/italic/small-caps.
    pub font_synthesis: FontSynthesis,
    pub baseline_shift: BaselineShift,
    /// Fill, outline, or both.
    pub draw_style: DrawStyle,
    /// URL for clickable link text.
    pub url: Option<Arc<str>>,
    /// OpenType font variation settings (e.g. "wght 700, opsz 24").
    pub font_variation_settings: Option<Arc<str>>,
}

/// How an image is sampled and placed inside its [`Rect`], bundled for
/// ergonomic [`Default`]. `tint` stays on [`SceneNode::Image`] because
/// untinted is an explicit choice, not a neutral one.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct ImagePaintStyle {
    pub fit: ImageFit,
    pub filter: ImageFilter,
    /// Sub-region of the source texture to sample.
    pub source_rect: Option<ImageSourceRect>,
    pub alignment: ImageAlignment,
}

#[derive(Clone, Copy, Debug)]
pub struct PaintCallbackInfo {
    /// Viewport in physical pixels where the callback should paint.
    pub viewport: Rect,
    /// Clip rect in physical pixels (intersection of callback rect and current clip).
    pub clip_rect: Rect,
    /// Pixels per point (DPI scale).
    pub pixels_per_point: f32,
    /// Screen size in physical pixels.
    pub screen_size_px: [u32; 2],
}

pub type PaintCallbackPayload = Arc<dyn std::any::Any + Send + Sync>;

/// Paint-space scene graph. `Rect`/`Vec2` compounds carry physical pixels
/// (like Compose `Offset`/`Size`/`Rect`); scalar lengths use [`Px`] so the
/// dp→px boundary is explicit instead of unitless `f32`.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum SceneNode {
    Rect {
        rect: Rect,
        brush: Brush,
        radius: [Px; 4],
    },
    Border {
        rect: Rect,
        brush: Brush,
        width: Px,
        radius: [Px; 4],
    },
    Text {
        rect: Rect,
        text: Arc<str>,
        color: Color,
        size: Px,
        style: TextPaintStyle,
    },
    Ellipse {
        rect: Rect,
        brush: Brush,
    },
    EllipseBorder {
        rect: Rect,
        brush: Brush,
        width: Px,
    },
    PushClip {
        rect: Rect,
        radius: [Px; 4],
        op: ClipOp,
    },
    PopClip,
    PushTransform {
        transform: Transform,
    },
    PopTransform,
    Image {
        rect: Rect,
        handle: ImageHandle,
        tint: Color,
        style: ImagePaintStyle,
    },
    /// [`Image`](Self::Image) composited with an explicit blend mode. Kept
    /// additive so the existing `Image` constructors keep meaning Normal
    /// source-over. Separable modes (`Add`, `Multiply`, `Screen`, `Darken`,
    /// `Lighten`) use fixed-function blending; the rest isolate the image
    /// into a graphics layer and composite it with the backdrop-blend shader,
    /// exactly like [`VectorMesh`](Self::VectorMesh).
    ImageBlended {
        rect: Rect,
        handle: ImageHandle,
        tint: Color,
        style: ImagePaintStyle,
        blend: BlendMode,
    },
    /// Tinted A8 coverage mask: samples `handle` (registered with
    /// `register_coverage_a8`) as coverage and composites `color` with
    /// source-over. Lets hosts rasterize geometry once (e.g. cached glyph
    /// coverage tiles) and re-composite it per frame with a new color or
    /// position without re-uploading. `rect` positions the tile's top-left;
    /// its size is the registered tile size.
    Coverage {
        rect: Rect,
        handle: ImageHandle,
        color: Color,
    },
    /// Shadow behind a rounded rect, typically driven by `StateElevation`.
    /// The `elevation` field controls offset and alpha.
    Shadow {
        rect: Rect,
        radius: [Px; 4],
        elevation: Px,
        color: Color,
    },
    /// Mark the start of a graphics layer: the contained subtree is rendered
    /// into an offscreen texture and then composited back into the parent.
    /// `alpha` is the group-compositing alpha applied at composite time.
    /// `blur_radius_x` / `blur_radius_y` are the gaussian blur radii in [`Px`]
    /// applied to the layer before compositing (zero = no blur on that axis).
    /// `rectangle_edge` true = clamp edge pixels (Rectangle). False = transparent out-of-bounds (Unbounded).
    BeginLayer {
        rect: Rect,
        layer_id: u32,
        alpha: f32,
        blur_radius_x: Px,
        blur_radius_y: Px,
        rectangle_edge: bool,
    },
    /// Closes the graphics layer opened by the matching `BeginLayer`.
    EndLayer {
        layer_id: u32,
    },
    /// Draws a blurred drop shadow underneath a previously-rendered layer.
    /// Emitted between `EndLayer` and the layer's `CompositeLayer`. The
    /// quad samples the layer's texture with a 3x3 Gaussian blur and an
    /// optional vertical offset.
    CompositeShadow {
        layer_id: u32,
        blur_px: Px,
        offset_px: (Px, Px),
        color: Color,
        /// Corner radii shaping the shadow silhouette, in px. All zero = the
        /// silhouette follows the layer alpha unchanged.
        shape_px: [Px; 4],
    },
    /// Arc stroke
    Arc {
        rect: Rect,
        start_angle: f32,
        sweep_angle: f32,
        stroke_width: Px,
        brush: Brush,
        cap: StrokeCap,
    },
    /// Pre-tessellated vector mesh (fill or stroke geometry produced by the
    /// host, e.g. lyon tessellation). Vertices live in the mesh's own local
    /// space; `transform` is a 2x3 affine that maps local -> world pixels as
    /// `[m00, m01, m10, m11, tx, ty]` (`out = M * local + t`; identity is
    /// `[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]`) and is applied in the vertex shader.
    /// The current scene `PushTransform` stack is folded in on top of
    /// `transform`.
    VectorMesh {
        mesh: Arc<VectorMeshData>,
        transform: [f32; 6],
        paint: PaintDesc,
        /// Reserved for explicit clip assignment. Clipping is otherwise
        /// structural via `PushVectorClip`/`PopVectorClip`.
        clip: Option<u32>,
        blend: BlendMode,
    },
    /// One mesh, one paint, many placements, drawn as a single indexed draw
    /// with one instance per transform. Placement order is the transform
    /// order, so an alpha-blended run composites front-to-back last-wins
    /// exactly as N separate `VectorMesh` nodes would. Use it when the same
    /// geometry repeats - sprite frames, repeated icons, tiled fills - since
    /// `VectorMesh` costs one draw and one paint uniform per placement.
    VectorMeshInstanced {
        mesh: Arc<VectorMeshData>,
        transforms: Vec<[f32; 6]>,
        paint: PaintDesc,
        blend: BlendMode,
    },
    /// Screen-space overlays (handles, rubber bands, playhead). Each mesh
    /// is positioned in final device pixels and ignores the world
    /// PushTransform stack. Emit outside the viewport's world transform.
    VectorOverlay {
        meshes: Arc<[VectorMeshData]>,
    },
    /// Start a vector clip: the mesh is rendered into the stencil buffer
    /// (increment) and subsequent content is masked to it. Mirrors the
    /// rect-based `PushClip` but for arbitrary tessellated masks.
    /// `op` selects intersection (keep content inside the mask, the common
    /// case) or difference (cut the mask out, e.g. ASS `\iclip` drawings).
    /// Difference is exact for a lone mask and for a mask nested inside
    /// intersect clips; a normal clip nested inside a difference mask is
    /// best-effort (see the renderer docs).
    PushVectorClip {
        mesh: Arc<VectorMeshData>,
        op: ClipOp,
    },
    /// End a vector clip opened by `PushVectorClip`.
    PopVectorClip,
    /// Custom GPU paint callback (check `egui::PaintCallback`).
    Callback {
        rect: Rect,
        payload: PaintCallbackPayload,
    },
}

/// Graphics-layer ids live in non-overlapping bands because a single `Scene`
/// routinely mixes layers from several producers. `1..` counts up from the
/// producer side (`repose-ui`), the wgpu translator owns `0xE000_0000..` for
/// separable-blur scratch textures and `0xF000_0000..` for perspective
/// flattening, and canvas hosts own
/// `CANVAS_LAYER_ID_BASE..0xE000_0000`.
pub const CANVAS_LAYER_ID_BASE: u32 = 0x8000_0000;

/// Shared vertex/index buffers for a tessellated vector mesh.
#[derive(Clone, Debug, Default)]
pub struct VectorMeshData {
    pub vertices: Arc<[VectorVertex]>,
    pub indices: Arc<[u32]>,
}

/// Pre-tessellated vertex: local position, premultiplied-linear color, and a
/// free-form uv channel (unused for solid fills, reserved for texture/gradient
/// sampling).
#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(C)]
pub struct VectorVertex {
    pub pos: [f32; 2],
    pub color: [f32; 4],
    pub uv: [f32; 2],
}

/// Ceiling on the stops a multi-stop paint may carry. The renderer packs the
/// table into the per-mesh uniform, so this is a hard structural limit rather
/// than a soft recommendation; hosts should split longer ramps themselves.
pub const MAX_GRADIENT_STOPS: usize = 8;

/// One gradient stop. `offset` is parametric along the gradient axis (0 at the
/// start, 1 at the end). A stored offset is unconstrained; packing clamps it to
/// `0..=1` and forces it non-decreasing for the GPU, so a caller may set it
/// freely through [`GradientStops::as_mut_slice`].
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct GradientStop {
    pub offset: f32,
    pub color: Color,
}

/// Inline stop table for [`PaintDesc::LinearStops`] / [`PaintDesc::RadialStops`].
///
/// Stored by value (no heap, no lifetime) so `PaintDesc` stays `Copy` and
/// `SceneNode` stays lifetime-free. Longer input is truncated to the first
/// [`MAX_GRADIENT_STOPS`]: [`from_slice`](Self::from_slice) logs the overflow,
/// [`try_from_slice`](Self::try_from_slice) reports it instead.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct GradientStops {
    stops: [GradientStop; MAX_GRADIENT_STOPS],
    len: u8,
}

impl GradientStops {
    /// The first [`MAX_GRADIENT_STOPS`] of `slice`; logs how many were dropped.
    pub fn from_slice(slice: &[GradientStop]) -> Self {
        let mut stops = Self::default();
        for stop in slice {
            if !stops.push(*stop) {
                log::warn!(
                    "gradient has {} stops; keeping the first {MAX_GRADIENT_STOPS}",
                    slice.len()
                );
                break;
            }
        }
        stops
    }

    /// Like [`from_slice`](Self::from_slice) but `None` when `slice` does not
    /// fit, so hosts can validate before submitting a draw.
    pub fn try_from_slice(slice: &[GradientStop]) -> Option<Self> {
        if slice.len() > MAX_GRADIENT_STOPS {
            return None;
        }
        let mut stops = Self::default();
        for stop in slice {
            if !stops.push(*stop) {
                return None;
            }
        }
        Some(stops)
    }

    /// Append a stop; `false` when the table is already full.
    pub fn push(&mut self, stop: GradientStop) -> bool {
        let len = self.len as usize;
        if len >= MAX_GRADIENT_STOPS {
            return false;
        }
        self.stops[len] = stop;
        self.len = (len + 1) as u8;
        true
    }

    pub fn as_slice(&self) -> &[GradientStop] {
        &self.stops[..self.len as usize]
    }

    pub fn as_mut_slice(&mut self) -> &mut [GradientStop] {
        &mut self.stops[..self.len as usize]
    }

    pub fn len(&self) -> usize {
        self.len as usize
    }

    pub fn is_empty(&self) -> bool {
        self.len == 0
    }

    /// Lowest stop alpha in `0..=1`; `1.0` when empty (nothing is painted, so
    /// the translucency fast path may treat it as opaque).
    pub fn min_alpha(&self) -> f32 {
        self.as_slice()
            .iter()
            .map(|stop| stop.color.3 as f32 / 255.0)
            .fold(1.0, f32::min)
    }
}

impl<'a> From<&'a [GradientStop]> for GradientStops {
    fn from(slice: &'a [GradientStop]) -> Self {
        Self::from_slice(slice)
    }
}

/// How a `VectorMesh` is painted. Mirrors the [`Brush`] variants;
/// gradient endpoints live in the mesh's local space.
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum PaintDesc {
    /// Use per-vertex color.
    Solid,
    /// Two-stop linear gradient in the mesh's local space.
    Linear {
        start: Vec2,
        end: Vec2,
        start_color: Color,
        end_color: Color,
    },
    /// Radial gradient centered at `center` (mesh-local) with `radius`.
    Radial {
        center: Vec2,
        radius: f32,
        start_color: Color,
        end_color: Color,
    },
    /// Angular sweep around `center` (mesh-local), clockwise from 3 o'clock.
    Sweep {
        center: Vec2,
        start_color: Color,
        end_color: Color,
    },
    /// Multi-stop linear gradient from `start` to `end` (mesh-local), evaluated
    /// per fragment. With two stops at offsets 0 and 1 this renders identically
    /// to [`Linear`](Self::Linear); beyond the end stops the color clamps
    /// (`TileMode::Clamp`).
    LinearStops {
        start: Vec2,
        end: Vec2,
        stops: GradientStops,
    },
    /// Multi-stop radial gradient around `center` with `radius` (mesh-local),
    /// evaluated per fragment. Two stops at offsets 0 and 1 render identically
    /// to [`Radial`](Self::Radial).
    RadialStops {
        center: Vec2,
        radius: f32,
        stops: GradientStops,
    },
}

impl PaintDesc {
    /// Lowest alpha this paint can produce, for the renderer's translucency
    /// fast path. `None` when translucency comes from per-vertex colors alone.
    pub fn min_alpha(self) -> Option<f32> {
        match self {
            PaintDesc::Solid => None,
            PaintDesc::Linear {
                start_color,
                end_color,
                ..
            }
            | PaintDesc::Radial {
                start_color,
                end_color,
                ..
            }
            | PaintDesc::Sweep {
                start_color,
                end_color,
                ..
            } => Some((start_color.3.min(end_color.3) as f32 / 255.0).clamp(0.0, 1.0)),
            PaintDesc::LinearStops { stops, .. } | PaintDesc::RadialStops { stops, .. } => {
                Some(stops.min_alpha())
            }
        }
    }
}

/// Blend mode for a `VectorMesh`, following the CSS `mix-blend-mode`
/// vocabulary used by SVG/Lottie-style artwork. All variants are wired into
/// the renderer: separable modes use fixed-function blending, the rest
/// (marked below) isolate the mesh into a graphics layer and composite it
/// with a custom shader.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
#[derive(Default)]
pub enum BlendMode {
    /// Standard premultiplied alpha blending (`normal`).
    #[default]
    Alpha,
    /// `screen`: `1 - (1 - S) * (1 - D)`. Fixed-function.
    Screen,
    /// `overlay`: `Multiply` for dark backdrops, `Screen` for light ones.
    Overlay,
    /// `darken`: per-channel `min(S, D)`. Fixed-function.
    Darken,
    /// `lighten`: per-channel `max(S, D)`. Fixed-function.
    Lighten,
    /// `color-dodge`: brightens the backdrop toward the source color.
    ColorDodge,
    /// `color-burn`: darkens the backdrop toward the source color.
    ColorBurn,
    /// `hard-light`: `Overlay` with source and backdrop swapped.
    HardLight,
    /// `soft-light`: subtle `Overlay` variant.
    SoftLight,
    /// `difference`: per-channel `|S - D|`.
    /// Needs the backdrop (fixed-function subtract only covers `S - D`),
    /// so it composites via an isolated layer.
    Difference,
    /// `exclusion`: like `difference` with lower contrast.
    Exclusion,
    /// `hue`: source hue with backdrop saturation and luminosity.
    Hue,
    /// `saturation`: source saturation with backdrop hue and luminosity.
    Saturation,
    /// `color`: source hue and saturation with backdrop luminosity.
    Color,
    /// `luminosity`: source luminosity with backdrop hue and saturation.
    Luminosity,
    /// Additive (screen-space light). Fixed-function.
    Add,
    /// `multiply`: per-channel `S * D`. Fixed-function.
    Multiply,
}

impl BlendMode {
    /// Modes implemented with fixed-function hardware blending on the mesh
    /// pipeline. Everything else isolates the mesh into a graphics layer and
    /// composites it with the backdrop-blend shader.
    pub fn needs_isolation(self) -> bool {
        !matches!(
            self,
            BlendMode::Alpha
                | BlendMode::Add
                | BlendMode::Multiply
                | BlendMode::Screen
                | BlendMode::Darken
                | BlendMode::Lighten
        )
    }

    /// Discriminant consumed by `blend_layer.wgsl` (`mode` flat varying).
    pub fn shader_mode(self) -> u32 {
        match self {
            BlendMode::Alpha => 0,
            BlendMode::Add => 1,
            BlendMode::Multiply => 2,
            BlendMode::Screen => 3,
            BlendMode::Overlay => 4,
            BlendMode::Darken => 5,
            BlendMode::Lighten => 6,
            BlendMode::ColorDodge => 7,
            BlendMode::ColorBurn => 8,
            BlendMode::HardLight => 9,
            BlendMode::SoftLight => 10,
            BlendMode::Difference => 11,
            BlendMode::Exclusion => 12,
            BlendMode::Hue => 13,
            BlendMode::Saturation => 14,
            BlendMode::Color => 15,
            BlendMode::Luminosity => 16,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum TextOverflow {
    Visible,
    Clip,
    Ellipsis,
}

/// Controls how line segments are joined in a stroked path.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum StrokeJoin {
    #[default]
    /// Sharp corner joins.
    Miter,
    /// Semi-circular joins.
    Round,
    /// Beveled (flat) joins.
    Bevel,
}

/// Controls how the endpoints of a stroked arc are drawn.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum StrokeCap {
    #[default]
    /// Flat ends at the exact arc endpoint. No extension.
    Butt,
    /// Semicircle with diameter equal to the stroke width, centered at the
    /// arc endpoint.
    Round,
    /// Flat-ended rectangle extending half the stroke width beyond the arc
    /// endpoint.
    Square,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn subcompose_scope_unbounded_has_infinite_max() {
        let s = SubcomposeScope::UNBOUNDED;
        assert!(!s.max_width.0.is_finite());
        assert!(!s.max_height.0.is_finite());
        assert_eq!(s.min_width, Dp(0.0));
        assert_eq!(s.min_height, Dp(0.0));
    }

    #[test]
    fn subcompose_scope_new_round_trips() {
        let s = SubcomposeScope::new(Dp(10.0), Dp(200.0), Dp(20.0), Dp(300.0));
        assert_eq!(s.min_width, Dp(10.0));
        assert_eq!(s.max_width, Dp(200.0));
        assert_eq!(s.min_height, Dp(20.0));
        assert_eq!(s.max_height, Dp(300.0));
    }

    #[test]
    fn box_with_constraints_scope_bounded_predicates() {
        let bounded = BoxWithConstraintsScope {
            min_width: Dp(0.0),
            max_width: Dp(360.0),
            min_height: Dp(0.0),
            max_height: Dp(640.0),
        };
        assert!(bounded.has_bounded_width());
        assert!(bounded.has_bounded_height());

        let unbounded = BoxWithConstraintsScope {
            min_width: Dp(0.0),
            max_width: Dp(f32::INFINITY),
            min_height: Dp(0.0),
            max_height: Dp(f32::INFINITY),
        };
        assert!(!unbounded.has_bounded_width());
        assert!(!unbounded.has_bounded_height());
    }

    #[test]
    fn view_kind_subcompose_layout_holds_closure() {
        let v: View = View {
            id: 0,
            kind: ViewKind::SubcomposeLayout {
                content: std::sync::Arc::new(|scope| {
                    let _ = scope.max_width;
                    vec![(0, View::new(0, ViewKind::Box))]
                }),
            },
            modifier: Modifier::default(),
            children: vec![],
            scope_key: None,
            semantics: None,
        };
        match &v.kind {
            ViewKind::SubcomposeLayout { .. } => {}
            _ => panic!("expected SubcomposeLayout"),
        }
    }

    #[test]
    fn view_kind_subcompose_layout_supports_multiple_slots() {
        let v: View = View {
            id: 0,
            kind: ViewKind::SubcomposeLayout {
                content: std::sync::Arc::new(|_scope| {
                    vec![
                        (1, View::new(0, ViewKind::Box)),
                        (2, View::new(0, ViewKind::Box)),
                        (3, View::new(0, ViewKind::Box)),
                    ]
                }),
            },
            modifier: Modifier::default(),
            children: vec![],
            scope_key: None,
            semantics: None,
        };
        if let ViewKind::SubcomposeLayout { content } = &v.kind {
            let slots = content(&SubcomposeScope::UNBOUNDED);
            assert_eq!(slots.len(), 3);
            assert_eq!(slots[0].0, 1);
            assert_eq!(slots[1].0, 2);
            assert_eq!(slots[2].0, 3);
        } else {
            panic!("expected SubcomposeLayout");
        }
    }
}