Skip to main content

repose_core/
view.rs

1use crate::units::{Dp, Px, Sp};
2use crate::{
3    BaselineShift, Brush, ClipOp, Color, DrawStyle, FontStyle, FontSynthesis, FontWeight, Modifier,
4    Rect, TextAlign, TextDecoration, TextDirection, TextSpan, Transform, Vec2,
5};
6use std::{fmt::Formatter, sync::Arc};
7
8/// The constraints that will be passed to a subcomposed child. Values are in
9/// [`Dp`], matching the units used by `Modifier`.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct SubcomposeScope {
12    pub min_width: Dp,
13    pub max_width: Dp,
14    pub min_height: Dp,
15    pub max_height: Dp,
16}
17
18impl SubcomposeScope {
19    /// A scope with no finite upper bounds. Values are density-independent
20    /// pixels (`Dp`).
21    pub const UNBOUNDED: Self = Self {
22        min_width: Dp(0.0),
23        max_width: Dp(f32::INFINITY),
24        min_height: Dp(0.0),
25        max_height: Dp(f32::INFINITY),
26    };
27
28    /// Construct a scope from raw min/max [`Dp`] values. Non-finite minimums
29    /// and negative values are coerced to zero, a NaN maximum becomes
30    /// unbounded, and inverted bounds collapse to the maximum.
31    pub fn new(min_width: Dp, max_width: Dp, min_height: Dp, max_height: Dp) -> Self {
32        fn minimum(value: f32) -> f32 {
33            if value.is_nan() || value < 0.0 || !value.is_finite() {
34                0.0
35            } else {
36                value
37            }
38        }
39        fn maximum(value: f32) -> f32 {
40            if value.is_nan() || value < 0.0 {
41                if value.is_nan() { f32::INFINITY } else { 0.0 }
42            } else {
43                value
44            }
45        }
46        let mut scope = Self {
47            min_width: Dp(minimum(min_width.0)),
48            max_width: Dp(maximum(max_width.0)),
49            min_height: Dp(minimum(min_height.0)),
50            max_height: Dp(maximum(max_height.0)),
51        };
52        if scope.min_width.0 > scope.max_width.0 {
53            scope.min_width = scope.max_width;
54        }
55        if scope.min_height.0 > scope.max_height.0 {
56            scope.min_height = scope.max_height;
57        }
58        scope
59    }
60
61    pub fn has_bounded_width(&self) -> bool {
62        self.max_width.0.is_finite()
63    }
64
65    pub fn has_bounded_height(&self) -> bool {
66        self.max_height.0.is_finite()
67    }
68}
69
70/// Scope passed to [`BoxWithConstraints`](crate::prelude::BoxWithConstraints)
71/// content. All values are in [`Dp`].
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct BoxWithConstraintsScope {
74    pub min_width: Dp,
75    pub max_width: Dp,
76    pub min_height: Dp,
77    pub max_height: Dp,
78}
79
80impl BoxWithConstraintsScope {
81    /// `true` if the width is bounded by the parent (i.e. not infinite).
82    pub fn has_bounded_width(&self) -> bool {
83        self.max_width.0.is_finite()
84    }
85
86    /// `true` if the height is bounded by the parent (i.e. not infinite).
87    pub fn has_bounded_height(&self) -> bool {
88        self.max_height.0.is_finite()
89    }
90}
91
92pub type ViewId = u64;
93
94pub type ImageHandle = u64;
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
96pub enum ImageFilter {
97    #[default]
98    Linear,
99    Nearest,
100}
101
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
103pub struct ImageSourceRect {
104    pub x: u32,
105    pub y: u32,
106    pub width: u32,
107    pub height: u32,
108}
109
110impl ImageSourceRect {
111    pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
112        Self {
113            x,
114            y,
115            width,
116            height,
117        }
118    }
119
120    pub const fn is_empty(self) -> bool {
121        self.width == 0 || self.height == 0
122    }
123}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126#[non_exhaustive]
127pub enum ImageFit {
128    /// ContentScale.Fit - default in Compose Image
129    Contain,
130    /// ContentScale.Crop
131    Cover,
132    /// ContentScale.FillWidth
133    FitWidth,
134    /// ContentScale.FillHeight
135    FitHeight,
136    /// ContentScale.FillBounds - stretch, ignore aspect
137    FillBounds,
138    /// ContentScale.Inside - like Contain but never upscales
139    Inside,
140    /// ContentScale.None - no scaling; placement comes from [`ImageAlignment`]
141    None,
142    /// Repeat the source to fill the bounds (Godot `STRETCH_SCALE` + tile).
143    /// Unlike every other mode this ignores aspect ratio and may draw past a
144    /// `source_rect` edge, wrapping instead of clipping.
145    Tile,
146}
147
148/// Placement of scaled content inside the view bounds. Mirrors Godot's
149/// `AspectRatioContainer::AlignmentMode` and covers every fixed `Alignment` in
150/// Compose (`Begin`/`End` are physical left/right and top/bottom, not
151/// direction-aware).
152#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
153pub enum ImageAlignment {
154    /// Alignment.Center
155    #[default]
156    Center,
157    /// Alignment.TopLeft / BottomStart - left and top
158    Begin,
159    /// Alignment.TopRight / BottomEnd - right and bottom
160    End,
161}
162
163impl ImageFit {
164    /// Per-axis scale factors for drawing a `src`-sized source into `dst`.
165    /// Mirrors Compose's `ContentScale.computeScaleFactor`, which layout and
166    /// paint both need: layout sizes to it, paint draws with it.
167    pub fn scale_factor(self, src: (f32, f32), dst: (f32, f32)) -> (f32, f32) {
168        let fill_w = dst.0 / src.0;
169        let fill_h = dst.1 / src.1;
170        let uniform = |v: f32| (v, v);
171        match self {
172            Self::Contain => uniform(fill_w.min(fill_h)),
173            Self::Cover => uniform(fill_w.max(fill_h)),
174            Self::FitWidth => uniform(fill_w),
175            Self::FitHeight => uniform(fill_h),
176            Self::FillBounds => (fill_w, fill_h),
177            Self::Inside => {
178                if src.0 <= dst.0 && src.1 <= dst.1 {
179                    uniform(1.0)
180                } else {
181                    uniform(fill_w.min(fill_h))
182                }
183            }
184            Self::None | Self::Tile => uniform(1.0),
185        }
186    }
187}
188
189#[derive(Clone)]
190pub struct OverlayEntry {
191    pub id: u64,
192    pub view: Box<View>,
193}
194
195#[derive(Clone)]
196#[non_exhaustive]
197pub enum ViewKind {
198    Box,
199    Row,
200    Column,
201    ZStack,
202    OverlayHost,
203    Text {
204        text: String,
205        color: Color,
206        font_size: Sp,
207        soft_wrap: bool,
208        max_lines: Option<usize>,
209        overflow: TextOverflow,
210        font_family: Option<&'static str>,
211        annotations: Option<Arc<[TextSpan]>>,
212        text_align: TextAlign,
213        font_weight: FontWeight,
214        font_style: FontStyle,
215        text_decoration: TextDecoration,
216        letter_spacing: Sp,
217        line_height: Sp,
218        /// URL for clickable link text.
219        url: Option<Arc<str>>,
220        /// OpenType font variation settings (e.g. "wght 700, opsz 24").
221        font_variation_settings: Option<Arc<str>>,
222        /// Fill, outline, or both (faux-bold). Default `Fill`.
223        draw_style: DrawStyle,
224    },
225
226    Image {
227        handle: ImageHandle,
228        tint: Color,
229        fit: ImageFit,
230        filter: ImageFilter,
231        source_rect: Option<ImageSourceRect>,
232        alignment: ImageAlignment,
233    },
234    /// A layout whose children are produced by calling `content` with the
235    /// current `SubcomposeScope`. The closure is invoked during reconciliation
236    /// and returns a list of `(slot_id, view)` pairs. Each slot id is a stable
237    /// identity used to reconcile the returned view across frames. This is
238    /// the building block for `BoxWithConstraints` and other
239    /// constraints-driven layouts.
240    ///
241    /// Note: any `Modifier::key` set on a returned view is overwritten by its
242    /// slot id so the slot's identity is stable across frames.
243    SubcomposeLayout {
244        content: Arc<dyn Fn(&SubcomposeScope) -> Vec<(u64, View)>>,
245    },
246}
247
248impl std::fmt::Debug for ViewKind {
249    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
250        match self {
251            Self::Box => f.write_str("Box"),
252            Self::Row => f.write_str("Row"),
253            Self::Column => f.write_str("Column"),
254            Self::ZStack => f.write_str("ZStack"),
255            Self::OverlayHost => f.write_str("OverlayHost"),
256
257            Self::Image {
258                handle,
259                tint,
260                fit,
261                filter,
262                source_rect,
263                alignment,
264            } => f
265                .debug_struct("Image")
266                .field("handle", handle)
267                .field("tint", tint)
268                .field("fit", fit)
269                .field("filter", filter)
270                .field("source_rect", source_rect)
271                .field("alignment", alignment)
272                .finish(),
273            Self::SubcomposeLayout { .. } => f.write_str("SubcomposeLayout"),
274            Self::Text { text, .. } => write!(f, "Text({:?})", text),
275        }
276    }
277}
278
279#[derive(Clone, Debug)]
280pub struct View {
281    pub id: ViewId,
282    pub kind: ViewKind,
283    pub modifier: Modifier,
284    pub children: Vec<View>,
285    pub semantics: Option<crate::semantics::Semantics>,
286    /// Set by `scope!` macro to mark this as a scope boundary node.
287    /// Carries the scope key (e.g., "title", "color_buttons") for per-scope
288    /// TaffyTree isolation.
289    pub scope_key: Option<String>,
290}
291
292impl View {
293    pub fn new(id: ViewId, kind: ViewKind) -> Self {
294        View {
295            id,
296            kind,
297            modifier: Modifier::default(),
298            children: vec![],
299            semantics: None,
300            scope_key: None,
301        }
302    }
303    pub fn modifier(mut self, m: Modifier) -> Self {
304        self.modifier = m;
305        self
306    }
307    /// Mark this view as disabled - ignores pointer events.
308    pub fn disabled(mut self) -> Self {
309        self.modifier.disabled = true;
310        self
311    }
312    pub fn with_children(mut self, kids: Vec<View>) -> Self {
313        self.children = kids;
314        self
315    }
316    pub fn children(mut self, kids: impl Into<Vec<View>>) -> Self {
317        self.children = kids.into();
318        self
319    }
320    pub fn semantics(mut self, s: crate::semantics::Semantics) -> Self {
321        self.semantics = Some(s);
322        self
323    }
324}
325
326/// Renderable scene
327#[derive(Clone, Debug, Default)]
328pub struct Scene {
329    pub clear_color: Color,
330    pub nodes: Vec<SceneNode>,
331}
332
333/// Rarely-tweaked text style properties bundled for ergonomic Default.
334#[derive(Clone, Debug, PartialEq)]
335pub struct TextExtraStyle {
336    pub text_direction: TextDirection,
337    pub font_synthesis: FontSynthesis,
338    pub baseline_shift: BaselineShift,
339    pub draw_style: DrawStyle,
340}
341
342impl Default for TextExtraStyle {
343    fn default() -> Self {
344        Self {
345            text_direction: TextDirection::Ltr,
346            font_synthesis: FontSynthesis::Unspecified,
347            baseline_shift: BaselineShift::Unspecified,
348            draw_style: DrawStyle::Fill,
349        }
350    }
351}
352
353#[derive(Clone, Copy, Debug)]
354pub struct PaintCallbackInfo {
355    /// Viewport in physical pixels where the callback should paint.
356    pub viewport: Rect,
357    /// Clip rect in physical pixels (intersection of callback rect and current clip).
358    pub clip_rect: Rect,
359    /// Pixels per point (DPI scale).
360    pub pixels_per_point: f32,
361    /// Screen size in physical pixels.
362    pub screen_size_px: [u32; 2],
363}
364
365pub type PaintCallbackPayload = Arc<dyn std::any::Any + Send + Sync>;
366
367/// Paint-space scene graph. `Rect`/`Vec2` compounds carry physical pixels
368/// (like Compose `Offset`/`Size`/`Rect`); scalar lengths use [`Px`] so the
369/// dp→px boundary is explicit instead of unitless `f32`.
370#[derive(Clone, Debug)]
371#[non_exhaustive]
372pub enum SceneNode {
373    Rect {
374        rect: Rect,
375        brush: Brush,
376        radius: [Px; 4],
377    },
378    Border {
379        rect: Rect,
380        brush: Brush,
381        width: Px,
382        radius: [Px; 4],
383    },
384    Text {
385        rect: Rect,
386        text: Arc<str>,
387        color: Color,
388        size: Px,
389        font_family: Option<&'static str>,
390        text_align: TextAlign,
391        font_weight: FontWeight,
392        font_style: FontStyle,
393        text_decoration: TextDecoration,
394        letter_spacing: Px,
395        line_height: Px,
396        /// Rarely-tweaked style properties, bundled for ergonomic Default.
397        extra_style: TextExtraStyle,
398        /// URL for clickable link text.
399        url: Option<Arc<str>>,
400        /// OpenType font variation settings (e.g. "wght 700, opsz 24").
401        font_variation_settings: Option<Arc<str>>,
402    },
403    Ellipse {
404        rect: Rect,
405        brush: Brush,
406    },
407    EllipseBorder {
408        rect: Rect,
409        brush: Brush,
410        width: Px,
411    },
412    PushClip {
413        rect: Rect,
414        radius: [Px; 4],
415        op: ClipOp,
416    },
417    PopClip,
418    PushTransform {
419        transform: Transform,
420    },
421    PopTransform,
422    Image {
423        rect: Rect,
424        handle: ImageHandle,
425        tint: Color,
426        fit: ImageFit,
427        filter: ImageFilter,
428        source_rect: Option<ImageSourceRect>,
429        alignment: ImageAlignment,
430    },
431    /// Tinted A8 coverage mask: samples `handle` (registered with
432    /// `register_coverage_a8`) as coverage and composites `color` with
433    /// source-over. Lets hosts rasterize geometry once (e.g. cached glyph
434    /// coverage tiles) and re-composite it per frame with a new color or
435    /// position without re-uploading. `rect` positions the tile's top-left;
436    /// its size is the registered tile size.
437    Coverage {
438        rect: Rect,
439        handle: ImageHandle,
440        color: Color,
441    },
442    /// Shadow behind a rounded rect, typically driven by `StateElevation`.
443    /// The `elevation` field controls offset and alpha.
444    Shadow {
445        rect: Rect,
446        radius: [Px; 4],
447        elevation: Px,
448        color: Color,
449    },
450    /// Mark the start of a graphics layer: the contained subtree is rendered
451    /// into an offscreen texture and then composited back into the parent.
452    /// `alpha` is the group-compositing alpha applied at composite time.
453    /// `blur_radius_x` / `blur_radius_y` are the gaussian blur radii in [`Px`]
454    /// applied to the layer before compositing (zero = no blur on that axis).
455    /// `rectangle_edge` true = clamp edge pixels (Rectangle). False = transparent out-of-bounds (Unbounded).
456    BeginLayer {
457        rect: Rect,
458        layer_id: u32,
459        alpha: f32,
460        blur_radius_x: Px,
461        blur_radius_y: Px,
462        rectangle_edge: bool,
463    },
464    /// Closes the graphics layer opened by the matching `BeginLayer`.
465    EndLayer {
466        layer_id: u32,
467    },
468    /// Draws a blurred drop shadow underneath a previously-rendered layer.
469    /// Emitted between `EndLayer` and the layer's `CompositeLayer`. The
470    /// quad samples the layer's texture with a 3x3 Gaussian blur and an
471    /// optional vertical offset.
472    CompositeShadow {
473        layer_id: u32,
474        blur_px: Px,
475        offset_px: (Px, Px),
476        color: Color,
477    },
478    /// Arc stroke
479    Arc {
480        rect: Rect,
481        start_angle: f32,
482        sweep_angle: f32,
483        stroke_width: Px,
484        brush: Brush,
485        cap: StrokeCap,
486    },
487    /// Pre-tessellated vector mesh (fill or stroke geometry produced by the
488    /// host, e.g. lyon tessellation). Vertices live in the mesh's own local
489    /// space; `transform` is a 2x3 affine that maps local -> world pixels as
490    /// `[m00, m01, m10, m11, tx, ty]` (`out = M * local + t`; identity is
491    /// `[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]`) and is applied in the vertex shader.
492    /// The current scene `PushTransform` stack is folded in on top of
493    /// `transform`.
494    VectorMesh {
495        mesh: Arc<VectorMeshData>,
496        transform: [f32; 6],
497        paint: PaintDesc,
498        /// Reserved for explicit clip assignment. Clipping is otherwise
499        /// structural via `PushVectorClip`/`PopVectorClip`.
500        clip: Option<u32>,
501        blend: BlendMode,
502    },
503    /// Screen-space overlays (handles, rubber bands, playhead). Each mesh
504    /// is positioned in final device pixels and ignores the world
505    /// PushTransform stack. Emit outside the viewport's world transform.
506    VectorOverlay {
507        meshes: Arc<[VectorMeshData]>,
508    },
509    /// Start a vector clip: the mesh is rendered into the stencil buffer
510    /// (increment) and subsequent content is masked to it. Mirrors the
511    /// rect-based `PushClip` but for arbitrary tessellated masks.
512    /// `op` selects intersection (keep content inside the mask, the common
513    /// case) or difference (cut the mask out, e.g. ASS `\iclip` drawings).
514    /// Difference is exact for a lone mask and for a mask nested inside
515    /// intersect clips; a normal clip nested inside a difference mask is
516    /// best-effort (see the renderer docs).
517    PushVectorClip {
518        mesh: Arc<VectorMeshData>,
519        op: ClipOp,
520    },
521    /// End a vector clip opened by `PushVectorClip`.
522    PopVectorClip,
523    /// Custom GPU paint callback (check `egui::PaintCallback`).
524    Callback {
525        rect: Rect,
526        payload: PaintCallbackPayload,
527    },
528}
529
530/// Shared vertex/index buffers for a tessellated vector mesh.
531#[derive(Clone, Debug, Default)]
532pub struct VectorMeshData {
533    pub vertices: Arc<[VectorVertex]>,
534    pub indices: Arc<[u32]>,
535}
536
537/// Pre-tessellated vertex: local position, premultiplied-linear color, and a
538/// free-form uv channel (unused for solid fills, reserved for texture/gradient
539/// sampling).
540#[derive(Clone, Copy, Debug, PartialEq)]
541#[repr(C)]
542pub struct VectorVertex {
543    pub pos: [f32; 2],
544    pub color: [f32; 4],
545    pub uv: [f32; 2],
546}
547
548/// How a `VectorMesh` is painted. Mirrors the [`Brush`] variants;
549/// gradient endpoints live in the mesh's local space.
550#[derive(Clone, Copy, Debug, PartialEq)]
551#[non_exhaustive]
552pub enum PaintDesc {
553    /// Use per-vertex color.
554    Solid,
555    /// Two-stop linear gradient in the mesh's local space.
556    Linear {
557        start: Vec2,
558        end: Vec2,
559        start_color: Color,
560        end_color: Color,
561    },
562    /// Radial gradient centered at `center` (mesh-local) with `radius`.
563    Radial {
564        center: Vec2,
565        radius: f32,
566        start_color: Color,
567        end_color: Color,
568    },
569    /// Angular sweep around `center` (mesh-local), clockwise from 3 o'clock.
570    Sweep {
571        center: Vec2,
572        start_color: Color,
573        end_color: Color,
574    },
575}
576
577/// Blend mode for a `VectorMesh`, following the CSS `mix-blend-mode`
578/// vocabulary used by SVG/Lottie-style artwork. All variants are wired into
579/// the renderer: separable modes use fixed-function blending, the rest
580/// (marked below) isolate the mesh into a graphics layer and composite it
581/// with a custom shader.
582#[derive(Clone, Copy, Debug, PartialEq, Eq)]
583#[non_exhaustive]
584#[derive(Default)]
585pub enum BlendMode {
586    /// Standard premultiplied alpha blending (`normal`).
587    #[default]
588    Alpha,
589    /// `screen`: `1 - (1 - S) * (1 - D)`. Fixed-function.
590    Screen,
591    /// `overlay`: `Multiply` for dark backdrops, `Screen` for light ones.
592    Overlay,
593    /// `darken`: per-channel `min(S, D)`. Fixed-function.
594    Darken,
595    /// `lighten`: per-channel `max(S, D)`. Fixed-function.
596    Lighten,
597    /// `color-dodge`: brightens the backdrop toward the source color.
598    ColorDodge,
599    /// `color-burn`: darkens the backdrop toward the source color.
600    ColorBurn,
601    /// `hard-light`: `Overlay` with source and backdrop swapped.
602    HardLight,
603    /// `soft-light`: subtle `Overlay` variant.
604    SoftLight,
605    /// `difference`: per-channel `|S - D|`.
606    /// Needs the backdrop (fixed-function subtract only covers `S - D`),
607    /// so it composites via an isolated layer.
608    Difference,
609    /// `exclusion`: like `difference` with lower contrast.
610    Exclusion,
611    /// `hue`: source hue with backdrop saturation and luminosity.
612    Hue,
613    /// `saturation`: source saturation with backdrop hue and luminosity.
614    Saturation,
615    /// `color`: source hue and saturation with backdrop luminosity.
616    Color,
617    /// `luminosity`: source luminosity with backdrop hue and saturation.
618    Luminosity,
619    /// Additive (screen-space light). Fixed-function.
620    Add,
621    /// `multiply`: per-channel `S * D`. Fixed-function.
622    Multiply,
623}
624
625impl BlendMode {
626    /// Modes implemented with fixed-function hardware blending on the mesh
627    /// pipeline. Everything else isolates the mesh into a graphics layer and
628    /// composites it with the backdrop-blend shader.
629    pub fn needs_isolation(self) -> bool {
630        !matches!(
631            self,
632            BlendMode::Alpha
633                | BlendMode::Add
634                | BlendMode::Multiply
635                | BlendMode::Screen
636                | BlendMode::Darken
637                | BlendMode::Lighten
638        )
639    }
640
641    /// Discriminant consumed by `blend_layer.wgsl` (`mode` flat varying).
642    pub fn shader_mode(self) -> u32 {
643        match self {
644            BlendMode::Alpha => 0,
645            BlendMode::Add => 1,
646            BlendMode::Multiply => 2,
647            BlendMode::Screen => 3,
648            BlendMode::Overlay => 4,
649            BlendMode::Darken => 5,
650            BlendMode::Lighten => 6,
651            BlendMode::ColorDodge => 7,
652            BlendMode::ColorBurn => 8,
653            BlendMode::HardLight => 9,
654            BlendMode::SoftLight => 10,
655            BlendMode::Difference => 11,
656            BlendMode::Exclusion => 12,
657            BlendMode::Hue => 13,
658            BlendMode::Saturation => 14,
659            BlendMode::Color => 15,
660            BlendMode::Luminosity => 16,
661        }
662    }
663}
664
665#[derive(Clone, Copy, Debug, PartialEq, Eq)]
666#[non_exhaustive]
667pub enum TextOverflow {
668    Visible,
669    Clip,
670    Ellipsis,
671}
672
673/// Controls how line segments are joined in a stroked path.
674#[derive(Clone, Copy, Debug, PartialEq, Default)]
675pub enum StrokeJoin {
676    #[default]
677    /// Sharp corner joins.
678    Miter,
679    /// Semi-circular joins.
680    Round,
681    /// Beveled (flat) joins.
682    Bevel,
683}
684
685/// Controls how the endpoints of a stroked arc are drawn.
686#[derive(Clone, Copy, Debug, PartialEq, Default)]
687pub enum StrokeCap {
688    #[default]
689    /// Flat ends at the exact arc endpoint. No extension.
690    Butt,
691    /// Semicircle with diameter equal to the stroke width, centered at the
692    /// arc endpoint.
693    Round,
694    /// Flat-ended rectangle extending half the stroke width beyond the arc
695    /// endpoint.
696    Square,
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn subcompose_scope_unbounded_has_infinite_max() {
705        let s = SubcomposeScope::UNBOUNDED;
706        assert!(!s.max_width.0.is_finite());
707        assert!(!s.max_height.0.is_finite());
708        assert_eq!(s.min_width, Dp(0.0));
709        assert_eq!(s.min_height, Dp(0.0));
710    }
711
712    #[test]
713    fn subcompose_scope_new_round_trips() {
714        let s = SubcomposeScope::new(Dp(10.0), Dp(200.0), Dp(20.0), Dp(300.0));
715        assert_eq!(s.min_width, Dp(10.0));
716        assert_eq!(s.max_width, Dp(200.0));
717        assert_eq!(s.min_height, Dp(20.0));
718        assert_eq!(s.max_height, Dp(300.0));
719    }
720
721    #[test]
722    fn box_with_constraints_scope_bounded_predicates() {
723        let bounded = BoxWithConstraintsScope {
724            min_width: Dp(0.0),
725            max_width: Dp(360.0),
726            min_height: Dp(0.0),
727            max_height: Dp(640.0),
728        };
729        assert!(bounded.has_bounded_width());
730        assert!(bounded.has_bounded_height());
731
732        let unbounded = BoxWithConstraintsScope {
733            min_width: Dp(0.0),
734            max_width: Dp(f32::INFINITY),
735            min_height: Dp(0.0),
736            max_height: Dp(f32::INFINITY),
737        };
738        assert!(!unbounded.has_bounded_width());
739        assert!(!unbounded.has_bounded_height());
740    }
741
742    #[test]
743    fn view_kind_subcompose_layout_holds_closure() {
744        let v: View = View {
745            id: 0,
746            kind: ViewKind::SubcomposeLayout {
747                content: std::sync::Arc::new(|scope| {
748                    let _ = scope.max_width;
749                    vec![(0, View::new(0, ViewKind::Box))]
750                }),
751            },
752            modifier: Modifier::default(),
753            children: vec![],
754            scope_key: None,
755            semantics: None,
756        };
757        match &v.kind {
758            ViewKind::SubcomposeLayout { .. } => {}
759            _ => panic!("expected SubcomposeLayout"),
760        }
761    }
762
763    #[test]
764    fn view_kind_subcompose_layout_supports_multiple_slots() {
765        let v: View = View {
766            id: 0,
767            kind: ViewKind::SubcomposeLayout {
768                content: std::sync::Arc::new(|_scope| {
769                    vec![
770                        (1, View::new(0, ViewKind::Box)),
771                        (2, View::new(0, ViewKind::Box)),
772                        (3, View::new(0, ViewKind::Box)),
773                    ]
774                }),
775            },
776            modifier: Modifier::default(),
777            children: vec![],
778            scope_key: None,
779            semantics: None,
780        };
781        if let ViewKind::SubcomposeLayout { content } = &v.kind {
782            let slots = content(&SubcomposeScope::UNBOUNDED);
783            assert_eq!(slots.len(), 3);
784            assert_eq!(slots[0].0, 1);
785            assert_eq!(slots[1].0, 2);
786            assert_eq!(slots[2].0, 3);
787        } else {
788            panic!("expected SubcomposeLayout");
789        }
790    }
791}