Skip to main content

frus_core/
scene.rs

1//! The [`Scene`]: a pure display list, independent of any rendering backend.
2//!
3//! It describes *what to draw* — primitives — while knowing nothing about the
4//! GPU. `frus-gpu` consumes it to produce GPU commands; `frus-widgets` produces
5//! it from a widget tree.
6//!
7//! Every primitive carries a **clip rectangle**, set through
8//! [`Scene::set_clip`] before primitives are added.
9
10use std::sync::Arc;
11
12use crate::{
13    Affine, BorderRadius, Color, FontWeight, ImageHandle, LayerFilter, Path, PathVerb, Point, Rect,
14    ResolvedTextStyle, ShaderMask, ShapeBorder, Size, Stroke, TextAlign, TextDecoration, TextRun,
15    TileMode,
16};
17
18/// The transform applied to a **layer** ([`Primitive::Layer`]) at compositing time:
19/// an arbitrary [`Affine`] (translation, per-axis scale, rotation, or any
20/// composition of them) in screen pixels. The layer is first rendered **flat** into
21/// a texture, as with group opacity, then composited **transformed** — so a single
22/// pass transforms a whole subtree (rects, text, images) without touching any
23/// individual primitive's shaders.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct LayerTransform {
26    /// The local (flat) → screen transform.
27    pub affine: Affine,
28}
29
30impl LayerTransform {
31    /// Wraps any [`Affine`].
32    pub const fn new(affine: Affine) -> Self {
33        Self { affine }
34    }
35
36    /// A rotation of `angle` radians about `pivot`, in screen pixels.
37    pub fn rotation(angle: f32, pivot: Point) -> Self {
38        Self {
39            affine: Affine::rotation(angle).about(pivot),
40        }
41    }
42
43    /// Conjugates the transform by a scale, so it follows `Primitive::scaled`:
44    /// `S ∘ M ∘ S⁻¹`, which is what makes a DPI change scale the whole layer, its
45    /// transform included.
46    pub fn scaled(self, factor: f32) -> Self {
47        self.scaled_xy(factor, factor)
48    }
49
50    /// Like [`LayerTransform::scaled`], but per axis.
51    pub fn scaled_xy(self, sx: f32, sy: f32) -> Self {
52        let conj = Affine::scale(1.0 / sx, 1.0 / sy)
53            .then(self.affine)
54            .then(Affine::scale(sx, sy));
55        Self { affine: conj }
56    }
57
58    /// Conjugates by a translation, so it follows `Primitive::translated`.
59    pub fn translated(self, dx: f32, dy: f32) -> Self {
60        let conj = Affine::translation(-dx, -dy)
61            .then(self.affine)
62            .then(Affine::translation(dx, dy));
63        Self { affine: conj }
64    }
65}
66
67/// **A gradient with any number of stops**, for a path: its shape, its colours, what it
68/// does past its ends, and where it sits.
69///
70/// The shape is in the gradient's own space and `transform` takes it to the path's, so a
71/// gradient that is turned, skewed or squashed — an SVG `gradientTransform`, a bounding
72/// box's units — is drawn exactly: the renderer maps each pixel back into the gradient's
73/// space, where the shape is simple.
74///
75/// ```
76/// use frus_core::{Affine, Color, GradientRamp, Point, RampShape, TileMode};
77/// let ramp = GradientRamp {
78///     shape: RampShape::Linear { from: Point::new(0.0, 0.0), to: Point::new(100.0, 0.0) },
79///     stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)],
80///     tile: TileMode::Clamp,
81///     transform: Affine::default(),
82/// };
83/// assert_eq!(ramp.t_at(Point::new(25.0, 40.0)), Some(0.25));
84/// ```
85#[derive(Clone, Debug, PartialEq)]
86pub struct GradientRamp {
87    /// Where the colours run, in the gradient's own space.
88    pub shape: RampShape,
89    /// `(offset, colour)` pairs, offsets from `0` to `1` in order. A colour holds before
90    /// the first stop and after the last.
91    pub stops: Vec<(f32, Color)>,
92    /// What happens past the ends of the shape.
93    pub tile: TileMode,
94    /// The gradient's space to the path's.
95    pub transform: Affine,
96}
97
98/// Where a [`GradientRamp`]'s colours run.
99#[derive(Clone, Copy, Debug, PartialEq)]
100pub enum RampShape {
101    /// The first stop at `from`, the last at `to`, constant across the line.
102    Linear {
103        /// Where offset `0` is.
104        from: Point,
105        /// Where offset `1` is.
106        to: Point,
107    },
108    /// Offset `0` on the circle about `focal` of radius `focal_radius`, offset `1` on the
109    /// circle about `center` of radius `radius`, and the circles between them for the
110    /// offsets between. With the focal point at the centre and no focal radius, it is the
111    /// plain outward fade.
112    Radial {
113        /// The centre of the outer circle.
114        center: Point,
115        /// The radius of the outer circle.
116        radius: f32,
117        /// The centre of the inner circle.
118        focal: Point,
119        /// The radius of the inner circle.
120        focal_radius: f32,
121    },
122}
123
124impl GradientRamp {
125    /// How far through the gradient the point `p` (in the path's space) is, tiled; `None`
126    /// where nothing is drawn — past the ends of a [`TileMode::Decal`] one, or outside
127    /// both circles' reach. What the renderer computes for every pixel.
128    pub fn t_at(&self, p: Point) -> Option<f32> {
129        let p = self.transform.inverse().apply(p);
130        let t = match self.shape {
131            RampShape::Linear { from, to } => {
132                let (dx, dy) = (to.x - from.x, to.y - from.y);
133                ((p.x - from.x) * dx + (p.y - from.y) * dy) / (dx * dx + dy * dy).max(1e-6)
134            }
135            RampShape::Radial {
136                center,
137                radius,
138                focal,
139                focal_radius,
140            } => {
141                // The largest t whose circle — centre and radius moving from the focal
142                // circle's to the outer one's — passes through p.
143                let (cdx, cdy) = (center.x - focal.x, center.y - focal.y);
144                let dr = radius - focal_radius;
145                let (pdx, pdy) = (p.x - focal.x, p.y - focal.y);
146                let a = cdx * cdx + cdy * cdy - dr * dr;
147                let b = pdx * cdx + pdy * cdy + focal_radius * dr;
148                let c = pdx * pdx + pdy * pdy - focal_radius * focal_radius;
149                if a.abs() < 1e-4 {
150                    c / (2.0 * b).max(1e-6)
151                } else {
152                    let disc = b * b - a * c;
153                    if disc < 0.0 {
154                        return None;
155                    }
156                    let root = disc.sqrt();
157                    let t = (b + root) / a;
158                    if focal_radius + t * dr < 0.0 {
159                        (b - root) / a
160                    } else {
161                        t
162                    }
163                }
164            }
165        };
166        crate::tile(t, self.tile)
167    }
168
169    /// The colour at offset `t`, between the stops either side of it.
170    pub fn sample(&self, t: f32) -> Color {
171        match self.stops.as_slice() {
172            [] => Color::TRANSPARENT,
173            [(_, only)] => *only,
174            stops => {
175                if t <= stops[0].0 {
176                    return stops[0].1;
177                }
178                for pair in stops.windows(2) {
179                    let ((a, from), (b, to)) = (pair[0], pair[1]);
180                    if t <= b {
181                        let k = if b > a { (t - a) / (b - a) } else { 1.0 };
182                        return from.lerp(to, k);
183                    }
184                }
185                stops[stops.len() - 1].1
186            }
187        }
188    }
189
190    /// The colour at the point `p`, in the path's space: transparent where nothing is
191    /// drawn.
192    pub fn color_at(&self, p: Point) -> Color {
193        self.t_at(p).map_or(Color::TRANSPARENT, |t| self.sample(t))
194    }
195
196    /// The same gradient after its path is moved by `then`.
197    fn followed_by(&self, then: Affine) -> GradientRamp {
198        GradientRamp {
199            transform: self.transform.then(then),
200            ..self.clone()
201        }
202    }
203}
204
205/// How a path's fill **fades**: along a line, or outwards from a centre.
206///
207/// The geometry is in the same space as the path, deliberately, so a fade can be
208/// aimed at something real — the edge a glow springs from, the ellipse whose cap is
209/// the glow — rather than at whatever bounding box the geometry happens to have.
210#[derive(Clone, Debug, PartialEq)]
211pub enum PathGradient {
212    /// The fill colour at `from`, `to_color` at `to`, clamped outside that span.
213    Linear {
214        /// The colour at `to`. The colour at `from` is the primitive's `fill`.
215        to_color: Color,
216        /// Where the fill colour holds.
217        from: Point,
218        /// Where `to_color` holds.
219        to: Point,
220    },
221    /// The fill colour on the ellipse of radii `inner × radii` about `center`,
222    /// `to_color` on the ellipse of radii `radii`, clamped outside.
223    ///
224    /// A curved edge needs this. A linear fade can only reach zero along a *line*,
225    /// so a shape whose boundary curves away from that line stops while the fade is
226    /// unfinished, and the leftover shows as an edge — which is the very thing a
227    /// glow must not have (milestone 302).
228    Radial {
229        /// The colour on the outer ellipse.
230        to_color: Color,
231        /// The centre of both ellipses.
232        center: Point,
233        /// The outer ellipse's radii, where `to_color` holds.
234        radii: Size,
235        /// Where the fade starts, as a fraction of `radii`. `0` fades from the
236        /// centre outwards; a value near `1` confines it to a thin rind.
237        inner: f32,
238    },
239    /// **Any number of colour stops**, along a line or between two circles, in a space of
240    /// its own — what vector artwork's gradients are. The primitive's fill colour
241    /// multiplies it: white leaves it as it is, and a faded white fades it.
242    Ramp(Arc<GradientRamp>),
243}
244
245impl PathGradient {
246    /// Scales the geometry per axis, as a DPI or paint scale does.
247    pub fn scaled_xy(self, sx: f32, sy: f32) -> Self {
248        match self {
249            Self::Linear { to_color, from, to } => Self::Linear {
250                to_color,
251                from: Point::new(from.x * sx, from.y * sy),
252                to: Point::new(to.x * sx, to.y * sy),
253            },
254            Self::Radial {
255                to_color,
256                center,
257                radii,
258                inner,
259            } => Self::Radial {
260                to_color,
261                center: Point::new(center.x * sx, center.y * sy),
262                radii: Size::new(radii.width * sx, radii.height * sy),
263                inner,
264            },
265            Self::Ramp(ramp) => Self::Ramp(Arc::new(ramp.followed_by(Affine::scale(sx, sy)))),
266        }
267    }
268
269    /// Moves the geometry with the shape it describes.
270    pub fn translated(self, dx: f32, dy: f32) -> Self {
271        match self {
272            Self::Linear { to_color, from, to } => Self::Linear {
273                to_color,
274                from: Point::new(from.x + dx, from.y + dy),
275                to: Point::new(to.x + dx, to.y + dy),
276            },
277            Self::Radial {
278                to_color,
279                center,
280                radii,
281                inner,
282            } => Self::Radial {
283                to_color,
284                center: Point::new(center.x + dx, center.y + dy),
285                radii,
286                inner,
287            },
288            Self::Ramp(ramp) => Self::Ramp(Arc::new(ramp.followed_by(Affine::translation(dx, dy)))),
289        }
290    }
291
292    /// The colour the fade ends on.
293    pub fn to_color(self) -> Color {
294        match self {
295            Self::Linear { to_color, .. } | Self::Radial { to_color, .. } => to_color,
296            Self::Ramp(ramp) => ramp.stops.last().map_or(Color::TRANSPARENT, |(_, c)| *c),
297        }
298    }
299
300    /// Fades the end colour with the fill it belongs to.
301    pub fn faded(self, opacity: f32) -> Self {
302        match self {
303            Self::Linear { to_color, from, to } => Self::Linear {
304                to_color: to_color.fade(opacity),
305                from,
306                to,
307            },
308            Self::Radial {
309                to_color,
310                center,
311                radii,
312                inner,
313            } => Self::Radial {
314                to_color: to_color.fade(opacity),
315                center,
316                radii,
317                inner,
318            },
319            // The fill colour multiplies a ramp, and the fill is faded with it.
320            Self::Ramp(ramp) => Self::Ramp(ramp),
321        }
322    }
323}
324
325/// **Which side of a shape a blur keeps** (milestone 645), the reference's `BlurStyle`.
326///
327/// A blurred shape fades across its edge, half in and half out. The other three styles
328/// keep one side of that fade and fill or empty the other: a solid shape with a glow, a
329/// glow with a hole where the shape was, a shape whose edge darkens inward.
330#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
331pub enum BlurStyle {
332    /// Fuzzy inside and outside: the shape blurred, what a shadow is.
333    #[default]
334    Normal,
335    /// Solid inside, fuzzy outside.
336    Solid,
337    /// Nothing inside, fuzzy outside.
338    Outer,
339    /// Fuzzy inside, nothing outside.
340    Inner,
341}
342
343impl BlurStyle {
344    /// The number the renderer reads.
345    pub const fn code(self) -> u32 {
346        match self {
347            BlurStyle::Normal => 0,
348            BlurStyle::Solid => 1,
349            BlurStyle::Outer => 2,
350            BlurStyle::Inner => 3,
351        }
352    }
353}
354
355/// The clip shape of a [`Primitive::Layer`], **inscribed** in its `clip` rectangle.
356/// Compositing multiplies the layer's alpha by the shape's coverage, with
357/// antialiased edges. This is the building block of the `ClipRRect`, `ClipOval`
358/// and `ClipPath` widgets.
359#[derive(Clone, Debug, Default, PartialEq)]
360pub enum ClipShape {
361    /// A crisp rectangular clip — the layer's `clip`, unchanged.
362    #[default]
363    Rect,
364    /// A **rounded-corner** rectangle, with a **per-corner** radius
365    /// ([`BorderRadius`], logical px) clamped to half the `clip`'s smaller side. A
366    /// uniform radius is still `BorderRadius::uniform(r)`.
367    RRect(BorderRadius),
368    /// An **ellipse** inscribed in the `clip` — a circle when the `clip` is square.
369    Oval,
370    /// An **arbitrary path**, in absolute screen coordinates: compositing renders it
371    /// into a coverage **mask** which it multiplies into the layer's alpha. The
372    /// building block of `ClipPath` — stars, notches, free-form shapes.
373    Path(Path),
374}
375
376impl ClipShape {
377    /// Follows a **per-axis** scaling of the layer (DPI, paint scale): only a
378    /// rounded radius changes, by the average of the two factors, since it has no
379    /// axis. The ellipse follows its `clip`, and the rectangle has nothing to scale.
380    pub fn scaled_xy(self, sx: f32, sy: f32) -> ClipShape {
381        match self {
382            ClipShape::RRect(br) => ClipShape::RRect(br.scale((sx + sy) * 0.5)),
383            // The path is in absolute coordinates, so DPI scaling follows it
384            // (uniformly — at DPI, `sx == sy`).
385            ClipShape::Path(p) => ClipShape::Path(p.scaled((sx + sy) * 0.5)),
386            other => other,
387        }
388    }
389}
390
391/// A drawing primitive.
392#[derive(Clone, Debug, PartialEq)]
393pub enum Primitive {
394    /// A rectangle: rounded corners, border, gradient and/or soft shadow.
395    Rect {
396        /// The rectangle's bounds.
397        rect: Rect,
398        /// Fill colour — the start colour when there is a gradient.
399        color: Color,
400        /// The gradient's end colour (`== color` when flat).
401        color2: Color,
402        /// Gradient direction in `[0,1]²` space; `(0,0)` means flat.
403        gradient_dir: [f32; 2],
404        /// Corner radii, per corner.
405        radius: BorderRadius,
406        /// Border width, in logical pixels; `0` draws no border.
407        border_width: f32,
408        /// Border colour.
409        border_color: Color,
410        /// Edge softening, in pixels (0 = crisp; > 0 = a blurred shadow).
411        blur: f32,
412        /// Clip rectangle: nothing is drawn outside it.
413        clip: Rect,
414        /// The emitting widget's identity, used for exit animation.
415        owner: u64,
416        /// **A gradient filling the shape** (milestone 644), as the reference describes it:
417        /// relative to `rect`, so it scales with it. When set it replaces `color`,
418        /// `color2` and `gradient_dir`, which hold its first and last colours for
419        /// whatever reads a rectangle's colour.
420        shader: Option<std::sync::Arc<crate::Gradient>>,
421        /// Where a blurred edge keeps its strength: either side of the shape, inside
422        /// only, outside only (milestone 645). Read only when `blur` is above nought.
423        blur_style: BlurStyle,
424    },
425    /// A line of text, anchored by its top-left corner.
426    Text {
427        /// The top-left corner the text is anchored at.
428        position: Point,
429        /// The text itself.
430        text: String,
431        /// The font size, in logical pixels.
432        size: f32,
433        /// The text's colour.
434        color: Color,
435        /// Font weight.
436        weight: FontWeight,
437        /// Italic.
438        italic: bool,
439        /// The width of the box the text was given (`None` = unbounded, in which case
440        /// explicit `\n` characters make the lines). It is what `soft_wrap` wraps at
441        /// and what `align` aligns inside.
442        max_width: Option<f32>,
443        /// Whether `max_width` **wraps** the text or only bounds the alignment. A line
444        /// that is told where its box ends but not to wrap runs past it, which is what
445        /// an ellipsised or clipped single line does.
446        soft_wrap: bool,
447        /// How the lines sit inside `max_width`. Ignored when there is no width to sit
448        /// inside — and deliberately so: handing the renderer a width it did not have
449        /// changes where right-to-left text lands.
450        align: TextAlign,
451        /// Decoration lines (underline, strikethrough, and so on).
452        decoration: TextDecoration,
453        /// Decoration colour; `None` means the text's own colour.
454        decoration_color: Option<Color>,
455        /// The line's height as a **multiple of `size`**; `None` means
456        /// [`crate::DEFAULT_LINE_HEIGHT`]. Carried on the primitive because the renderer
457        /// has to lay the lines out at the height the measurement used, and it cannot
458        /// derive that from the size alone.
459        height: Option<f32>,
460        /// The font family; `None` means the application's default. Carried for the same
461        /// reason as `height`: the renderer must shape with the face the measurement used,
462        /// and it cannot know which that was from the words alone.
463        family: Option<crate::FontFamily>,
464        /// The room added after every character, in logical pixels — carried for the same
465        /// reason again: the renderer must space the glyphs as the measurement did
466        /// (milestone 623).
467        letter_spacing: f32,
468        /// Clip rectangle.
469        clip: Rect,
470        /// The box the text was laid out in — the emitting widget's, so an
471        /// over-estimate of what the glyphs actually cover, never an under-estimate.
472        /// The renderer needs it to know what the text covers; without it text can
473        /// only be painted above everything else in the frame. `Rect::UNBOUNDED` when
474        /// a scene is built by hand rather than by the widget walk, which the renderer
475        /// reads as "unknown, keep it on top".
476        bounds: Rect,
477        /// The emitting widget's identity.
478        owner: u64,
479    },
480    /// **Rich** text: a sequence of resolved runs, mixing styles and colours, laid
481    /// out as one piece, on a single shared baseline.
482    RichText {
483        /// The top-left corner the text is anchored at.
484        position: Point,
485        /// The resolved runs, in reading order, sharing one baseline.
486        runs: Vec<TextRun>,
487        /// The width of the box the runs were given. See [`Primitive::Text`].
488        max_width: Option<f32>,
489        /// Whether `max_width` wraps the runs or only bounds the alignment.
490        soft_wrap: bool,
491        /// How the lines sit inside `max_width`.
492        align: TextAlign,
493        /// Clip rectangle.
494        clip: Rect,
495        /// The box the text was laid out in. See [`Primitive::Text`].
496        bounds: Rect,
497        /// The emitting widget's identity.
498        owner: u64,
499    },
500    /// A **vector path**: arbitrary 2D geometry, filled (`fill`) and/or stroked
501    /// (`stroke`). The building block of icons and custom drawing.
502    Path {
503        /// The geometry to fill and/or stroke.
504        path: Path,
505        /// Interior fill colour (`None` = no fill), and the gradient's start colour
506        /// when there is one.
507        fill: Option<Color>,
508        /// A gradient across the fill, straight or radial; `None` leaves it flat.
509        gradient: Option<PathGradient>,
510        /// Outline (colour plus width); `None` = no outline.
511        stroke: Option<Stroke>,
512        /// Clip rectangle.
513        clip: Rect,
514        /// The emitting widget's identity.
515        owner: u64,
516    },
517    /// A bitmap **image**, sampled into a destination rectangle.
518    Image {
519        /// Shared handle to the pixels (GPU-cached by [`crate::ImageData::id`]).
520        image: ImageHandle,
521        /// Destination rectangle, already fitted per the [`crate::BoxFit`].
522        rect: Rect,
523        /// The sampled sub-region of the texture, in `0..1` (the `Cover` crop).
524        uv: Rect,
525        /// Multiplicative tint (white = unchanged; the alpha drives fading).
526        tint: Color,
527        /// Whether sampling stays **strictly** inside `uv` (milestone 645): a filtered
528        /// sample never reaches the texels around it. The parts of a sliced picture
529        /// need it, or each would bleed its neighbours along its edges; a picture drawn
530        /// whole or cropped does without, as the reference's does.
531        strict: bool,
532        /// Clip rectangle.
533        clip: Rect,
534        /// The emitting widget's identity.
535        owner: u64,
536    },
537    /// A **layer**: a subgroup of primitives composited **as one** at `opacity`.
538    /// It is rendered separately into a texture at full opacity, then composited,
539    /// which is what makes the group alpha correct: no double blending where the
540    /// inner primitives overlap.
541    Layer {
542        /// The group's primitives, in absolute coordinates like the parent scene.
543        primitives: Vec<Primitive>,
544        /// Group opacity applied to the whole layer (`0..1`).
545        opacity: f32,
546        /// The layer's clip rectangle.
547        clip: Rect,
548        /// The clip **shape** inscribed in `clip` (rounded or elliptical); its
549        /// coverage modulates alpha at compositing time. [`ClipShape::Rect`] is a
550        /// plain rectangular clip, and the default.
551        clip_shape: ClipShape,
552        /// The affine transform (rotation) applied at compositing. `None` means the
553        /// layer is simply composited in place, with group opacity.
554        transform: Option<LayerTransform>,
555        /// The **pixel effects** applied when the layer is composited: an image
556        /// filter, a colour filter, a mask. [`LayerFilter::NONE`] — the usual
557        /// case — leaves compositing on its original path.
558        filter: LayerFilter,
559        /// The emitting widget's identity.
560        owner: u64,
561    },
562}
563
564impl Primitive {
565    /// The identity of the widget that emitted this primitive.
566    pub fn owner(&self) -> u64 {
567        match self {
568            Primitive::Rect { owner, .. } => *owner,
569            Primitive::Text { owner, .. } => *owner,
570            Primitive::RichText { owner, .. } => *owner,
571            Primitive::Path { owner, .. } => *owner,
572            Primitive::Image { owner, .. } => *owner,
573            Primitive::Layer { owner, .. } => *owner,
574        }
575    }
576
577    /// Scales the **geometry** by `factor` — position, size, radius, border, blur,
578    /// clip, font size. Colours and text are unchanged. This is how a logical scene
579    /// becomes a physical one (DPI).
580    pub fn scaled(&self, factor: f32) -> Primitive {
581        self.scaled_xy(factor, factor)
582    }
583
584    /// Scales the geometry **per axis** (`sx` horizontal, `sy` vertical).
585    /// Rectangles and images stretch exactly; **scalar** quantities (corner radius,
586    /// border, blur, path) follow the average of the two factors, having no axis of
587    /// their own; font size follows `sy` and wrap width follows `sx` — approximations
588    /// with no consequence when `sx == sy`, as with a uniform scale or DPI. This is
589    /// the basis of **non-uniform** subtree scaling.
590    pub fn scaled_xy(&self, sx: f32, sy: f32) -> Primitive {
591        let avg = (sx + sy) * 0.5;
592        match self.clone() {
593            Primitive::Rect {
594                rect,
595                color,
596                color2,
597                gradient_dir,
598                radius,
599                border_width,
600                border_color,
601                blur,
602                clip,
603                owner,
604                shader,
605                blur_style,
606            } => Primitive::Rect {
607                rect: rect.scale_xy(sx, sy),
608                color,
609                color2,
610                gradient_dir,
611                radius: radius.scale(avg),
612                border_width: border_width * avg,
613                border_color,
614                blur: blur * avg,
615                clip: clip.scale_xy(sx, sy),
616                owner,
617                shader,
618                blur_style,
619            },
620            Primitive::Text {
621                position,
622                text,
623                size,
624                color,
625                weight,
626                italic,
627                max_width,
628                soft_wrap,
629                align,
630                decoration,
631                decoration_color,
632                height,
633                family,
634                letter_spacing,
635                clip,
636                bounds,
637                owner,
638            } => Primitive::Text {
639                position: position.scale_xy(sx, sy),
640                text,
641                size: size * sy,
642                color,
643                weight,
644                italic,
645                max_width: max_width.map(|w| w * sx),
646                soft_wrap,
647                align,
648                decoration,
649                decoration_color,
650                height,
651                family,
652                letter_spacing: letter_spacing * sx,
653                clip: clip.scale_xy(sx, sy),
654                bounds: bounds.scale_xy(sx, sy),
655                owner,
656            },
657            Primitive::RichText {
658                position,
659                mut runs,
660                max_width,
661                soft_wrap,
662                align,
663                clip,
664                bounds,
665                owner,
666            } => {
667                for run in &mut runs {
668                    run.size *= sy;
669                }
670                Primitive::RichText {
671                    position: position.scale_xy(sx, sy),
672                    runs,
673                    max_width: max_width.map(|w| w * sx),
674                    soft_wrap,
675                    align,
676                    clip: clip.scale_xy(sx, sy),
677                    bounds: bounds.scale_xy(sx, sy),
678                    owner,
679                }
680            }
681            Primitive::Path {
682                path,
683                fill,
684                gradient,
685                stroke,
686                clip,
687                owner,
688            } => Primitive::Path {
689                path: path.scaled(avg),
690                fill,
691                gradient: gradient.map(|g| g.scaled_xy(sx, sy)),
692                stroke: stroke.map(|s| Stroke::new(s.color, s.width * avg)),
693                clip: clip.scale_xy(sx, sy),
694                owner,
695            },
696            Primitive::Image {
697                image,
698                rect,
699                uv,
700                tint,
701                strict,
702                clip,
703                owner,
704            } => Primitive::Image {
705                image,
706                rect: rect.scale_xy(sx, sy),
707                // The UV is in 0..1, so it is independent of scale.
708                uv,
709                tint,
710                strict,
711                clip: clip.scale_xy(sx, sy),
712                owner,
713            },
714            Primitive::Layer {
715                primitives,
716                opacity,
717                clip,
718                clip_shape,
719                transform,
720                filter,
721                owner,
722            } => Primitive::Layer {
723                primitives: primitives.iter().map(|p| p.scaled_xy(sx, sy)).collect(),
724                opacity,
725                clip: clip.scale_xy(sx, sy),
726                clip_shape: clip_shape.scaled_xy(sx, sy),
727                transform: transform.map(|t| t.scaled_xy(sx, sy)),
728                filter: filter.scaled_xy(sx, sy),
729                owner,
730            },
731        }
732    }
733
734    /// Offsets the **geometry** by `(dx, dy)` (position, clip); colours, sizes and
735    /// text are unchanged. Combined with [`Primitive::scaled`], it scales a subtree
736    /// **about a pivot**:
737    /// `p.scaled(f).translated(pivot.x * (1 - f), pivot.y * (1 - f))`.
738    pub fn translated(&self, dx: f32, dy: f32) -> Primitive {
739        match self.clone() {
740            Primitive::Rect {
741                rect,
742                color,
743                color2,
744                gradient_dir,
745                radius,
746                border_width,
747                border_color,
748                blur,
749                clip,
750                owner,
751                shader,
752                blur_style,
753            } => Primitive::Rect {
754                rect: rect.translate(dx, dy),
755                color,
756                color2,
757                gradient_dir,
758                radius,
759                border_width,
760                border_color,
761                blur,
762                clip: clip.translate(dx, dy),
763                owner,
764                shader,
765                blur_style,
766            },
767            Primitive::Text {
768                position,
769                text,
770                size,
771                color,
772                weight,
773                italic,
774                max_width,
775                soft_wrap,
776                align,
777                decoration,
778                decoration_color,
779                height,
780                family,
781                letter_spacing,
782                clip,
783                bounds,
784                owner,
785            } => Primitive::Text {
786                position: Point::new(position.x + dx, position.y + dy),
787                text,
788                size,
789                color,
790                weight,
791                italic,
792                max_width,
793                soft_wrap,
794                align,
795                decoration,
796                decoration_color,
797                height,
798                family,
799                letter_spacing,
800                clip: clip.translate(dx, dy),
801                bounds: bounds.translate(dx, dy),
802                owner,
803            },
804            Primitive::RichText {
805                position,
806                runs,
807                max_width,
808                soft_wrap,
809                align,
810                clip,
811                bounds,
812                owner,
813            } => Primitive::RichText {
814                position: Point::new(position.x + dx, position.y + dy),
815                runs,
816                max_width,
817                soft_wrap,
818                align,
819                clip: clip.translate(dx, dy),
820                bounds: bounds.translate(dx, dy),
821                owner,
822            },
823            Primitive::Path {
824                path,
825                fill,
826                gradient,
827                stroke,
828                clip,
829                owner,
830            } => Primitive::Path {
831                path: path.translated(dx, dy),
832                fill,
833                gradient: gradient.map(|g| g.translated(dx, dy)),
834                stroke,
835                clip: clip.translate(dx, dy),
836                owner,
837            },
838            Primitive::Image {
839                image,
840                rect,
841                uv,
842                tint,
843                strict,
844                clip,
845                owner,
846            } => Primitive::Image {
847                image,
848                rect: rect.translate(dx, dy),
849                uv,
850                tint,
851                strict,
852                clip: clip.translate(dx, dy),
853                owner,
854            },
855            Primitive::Layer {
856                primitives,
857                opacity,
858                clip,
859                clip_shape,
860                transform,
861                filter,
862                owner,
863            } => {
864                Primitive::Layer {
865                    primitives: primitives.iter().map(|p| p.translated(dx, dy)).collect(),
866                    opacity,
867                    clip: clip.translate(dx, dy),
868                    // Radius and ellipse are translation-invariant; only the `clip`
869                    // rectangle moves, which is handled above.
870                    clip_shape,
871                    transform: transform.map(|t| t.translated(dx, dy)),
872                    // A blur radius is a length, and does not move; a mask's geometry is
873                    // held in the scene's coordinates once resolved from its box, and moves
874                    // with what it covers (milestone 610: it was left behind).
875                    filter: filter.translated(dx, dy),
876                    owner,
877                }
878            }
879        }
880    }
881
882    /// This primitive's (approximate) bounding box, in scene coordinates.
883    /// Text is not measured in this module, so a `Text` or `RichText` primitive
884    /// returns a **point** rectangle at its position, enough as an x reference.
885    pub fn bounds(&self) -> Rect {
886        match self {
887            Primitive::Rect { rect, .. } | Primitive::Image { rect, .. } => *rect,
888            Primitive::Text { position, .. } | Primitive::RichText { position, .. } => {
889                Rect::new(position.x, position.y, 0.0, 0.0)
890            }
891            Primitive::Path { path, .. } => {
892                // The control points' bounding box — conservative bounds, since it
893                // encloses the Bézier controls and so is never smaller than the curve.
894                // Accumulated without allocating: min/max updated point by point.
895                let mut bbox: Option<(f32, f32, f32, f32)> = None;
896                let mut include = |p: Point| {
897                    bbox = Some(match bbox {
898                        None => (p.x, p.y, p.x, p.y),
899                        Some((x0, y0, x1, y1)) => {
900                            (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y))
901                        }
902                    });
903                };
904                for v in path.verbs() {
905                    match *v {
906                        PathVerb::MoveTo(p) | PathVerb::LineTo(p) => include(p),
907                        PathVerb::QuadTo { ctrl, to } => {
908                            include(ctrl);
909                            include(to);
910                        }
911                        PathVerb::CubicTo { c1, c2, to } => {
912                            include(c1);
913                            include(c2);
914                            include(to);
915                        }
916                        PathVerb::Close => {}
917                    }
918                }
919                match bbox {
920                    None => Rect::new(0.0, 0.0, 0.0, 0.0),
921                    Some((x0, y0, x1, y1)) => Rect::new(x0, y0, x1 - x0, y1 - y0),
922                }
923            }
924            Primitive::Layer { primitives, .. } => primitives
925                .iter()
926                .map(|p| p.bounds())
927                .reduce(|a, b| a.union(b))
928                .unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)),
929        }
930    }
931
932    /// A copy of this primitive with its **clip** replaced by `clip` — used to
933    /// "un-clip" a captured primitive replayed elsewhere, such as the ghost of a
934    /// dragged header, which overflows its source column.
935    pub fn with_clip(&self, clip: Rect) -> Primitive {
936        match self.clone() {
937            Primitive::Rect {
938                rect,
939                color,
940                color2,
941                gradient_dir,
942                radius,
943                border_width,
944                border_color,
945                blur,
946                owner,
947                ..
948            } => Primitive::Rect {
949                rect,
950                color,
951                color2,
952                gradient_dir,
953                radius,
954                border_width,
955                border_color,
956                blur,
957                clip,
958                owner,
959                shader: None,
960                blur_style: crate::BlurStyle::Normal,
961            },
962            Primitive::Text {
963                position,
964                text,
965                size,
966                color,
967                weight,
968                italic,
969                max_width,
970                soft_wrap,
971                align,
972                decoration,
973                decoration_color,
974                height,
975                family,
976                letter_spacing,
977                bounds,
978                owner,
979                ..
980            } => Primitive::Text {
981                position,
982                text,
983                size,
984                color,
985                weight,
986                italic,
987                max_width,
988                soft_wrap,
989                align,
990                decoration,
991                decoration_color,
992                height,
993                family,
994                letter_spacing,
995                clip,
996                bounds,
997                owner,
998            },
999            Primitive::RichText {
1000                position,
1001                runs,
1002                max_width,
1003                soft_wrap,
1004                align,
1005                bounds,
1006                owner,
1007                ..
1008            } => Primitive::RichText {
1009                position,
1010                runs,
1011                max_width,
1012                soft_wrap,
1013                align,
1014                clip,
1015                bounds,
1016                owner,
1017            },
1018            Primitive::Path {
1019                path,
1020                fill,
1021                gradient,
1022                stroke,
1023                owner,
1024                ..
1025            } => Primitive::Path {
1026                path,
1027                fill,
1028                gradient,
1029                stroke,
1030                clip,
1031                owner,
1032            },
1033            Primitive::Image {
1034                image,
1035                rect,
1036                uv,
1037                tint,
1038                strict,
1039                owner,
1040                ..
1041            } => Primitive::Image {
1042                image,
1043                rect,
1044                uv,
1045                tint,
1046                strict,
1047                clip,
1048                owner,
1049            },
1050            Primitive::Layer {
1051                primitives,
1052                opacity,
1053                clip_shape,
1054                transform,
1055                filter,
1056                owner,
1057                ..
1058            } => Primitive::Layer {
1059                primitives,
1060                opacity,
1061                clip,
1062                clip_shape,
1063                transform,
1064                filter,
1065                owner,
1066            },
1067        }
1068    }
1069
1070    /// Scales the geometry by `factor` **about `pivot`**, which stays fixed:
1071    /// `pos' = pivot + (pos - pivot) * factor`. Sizes, font, radii and strokes all
1072    /// follow the scale.
1073    pub fn scaled_about(&self, pivot: Point, factor: f32) -> Primitive {
1074        self.scaled_about_xy(pivot, factor, factor)
1075    }
1076
1077    /// Like [`Primitive::scaled_about`], but with **per-axis** factors (`sx`, `sy`)
1078    /// — a non-uniform scale about `pivot`.
1079    pub fn scaled_about_xy(&self, pivot: Point, sx: f32, sy: f32) -> Primitive {
1080        self.scaled_xy(sx, sy)
1081            .translated(pivot.x * (1.0 - sx), pivot.y * (1.0 - sy))
1082    }
1083}
1084
1085/// The box a block of text is laid out in: how wide it is, whether the text wraps at
1086/// that width, and where the lines sit inside it. See [`Scene::text_block`].
1087#[derive(Clone, Copy, Debug, PartialEq, Default)]
1088pub struct TextBlock {
1089    /// The box's width; `None` leaves the text unbounded, and alignment with it.
1090    pub width: Option<f32>,
1091    /// Whether the text wraps at `width` rather than running past it.
1092    pub soft_wrap: bool,
1093    /// Where the lines sit inside `width`.
1094    pub align: TextAlign,
1095}
1096
1097/// A 2D scene: the declarative description of what to draw.
1098#[derive(Clone, Debug)]
1099pub struct Scene {
1100    primitives: Vec<Primitive>,
1101    current_clip: Rect,
1102    current_owner: u64,
1103    /// The box of the widget currently painting — what text primitives record so the
1104    /// renderer knows what they cover. `UNBOUNDED` until a walk sets it.
1105    current_bounds: Rect,
1106}
1107
1108impl Default for Scene {
1109    fn default() -> Self {
1110        Self {
1111            primitives: Vec::new(),
1112            current_clip: Rect::UNBOUNDED,
1113            current_owner: 0,
1114            current_bounds: Rect::UNBOUNDED,
1115        }
1116    }
1117}
1118
1119impl Scene {
1120    /// Creates an empty scene, with a neutral clip.
1121    pub fn new() -> Self {
1122        Self::default()
1123    }
1124
1125    /// Empties the scene so it can be reused on the next frame.
1126    pub fn clear(&mut self) {
1127        self.primitives.clear();
1128        self.current_clip = Rect::UNBOUNDED;
1129        self.current_owner = 0;
1130        self.current_bounds = Rect::UNBOUNDED;
1131    }
1132
1133    /// Sets the clip rectangle applied to subsequent primitives.
1134    pub fn set_clip(&mut self, clip: Rect) {
1135        self.current_clip = clip;
1136    }
1137
1138    /// The current clip rectangle, for intersecting with local bounds.
1139    pub fn current_clip(&self) -> Rect {
1140        self.current_clip
1141    }
1142
1143    /// Sets the emitting widget's identity for subsequent primitives.
1144    pub fn set_owner(&mut self, owner: u64) {
1145        self.current_owner = owner;
1146    }
1147
1148    /// The emitting widget's identity, as the next primitive will carry it.
1149    pub fn current_owner(&self) -> u64 {
1150        self.current_owner
1151    }
1152
1153    /// Declares the box the widget about to paint was laid out in. Text primitives
1154    /// record it, which is how the renderer knows what a line of text covers — a
1155    /// `Primitive::Text` otherwise says only where it begins. Set by the widget walk
1156    /// before every `paint`; a scene built by hand leaves it `UNBOUNDED`, and the
1157    /// renderer then keeps that text above everything, as it always did.
1158    pub fn set_bounds(&mut self, bounds: Rect) {
1159        self.current_bounds = bounds;
1160    }
1161
1162    /// The box currently being painted into.
1163    pub fn current_bounds(&self) -> Rect {
1164        self.current_bounds
1165    }
1166
1167    /// Appends an **already-formed** primitive, with its clip and owner already
1168    /// baked in. This replays a cached subtree (a repaint boundary) as-is, without
1169    /// repainting it.
1170    pub fn push_primitive(&mut self, primitive: Primitive) {
1171        self.primitives.push(primitive);
1172    }
1173
1174    /// Removes and returns the primitives from index `start` onwards, preserving
1175    /// order. This **wraps** an already-painted subtree in a layer
1176    /// ([`Primitive::Layer`]): paint the subtree, then move its range of primitives
1177    /// into a layer, giving it group opacity.
1178    pub fn split_off(&mut self, start: usize) -> Vec<Primitive> {
1179        self.primitives.split_off(start)
1180    }
1181
1182    /// Replays an existing primitive at reduced opacity — an exit fade.
1183    pub fn push_faded(&mut self, primitive: &Primitive, opacity: f32) {
1184        let faded = match primitive.clone() {
1185            Primitive::Rect {
1186                rect,
1187                color,
1188                color2,
1189                gradient_dir,
1190                radius,
1191                border_width,
1192                border_color,
1193                blur,
1194                clip,
1195                owner,
1196                shader,
1197                blur_style,
1198            } => Primitive::Rect {
1199                rect,
1200                color: color.fade(opacity),
1201                color2: color2.fade(opacity),
1202                gradient_dir,
1203                radius,
1204                border_width,
1205                border_color: border_color.fade(opacity),
1206                blur,
1207                clip,
1208                owner,
1209                shader: shader.map(|g| std::sync::Arc::new(g.scale(opacity))),
1210                blur_style,
1211            },
1212            Primitive::Text {
1213                position,
1214                text,
1215                size,
1216                color,
1217                weight,
1218                italic,
1219                max_width,
1220                soft_wrap,
1221                align,
1222                decoration,
1223                decoration_color,
1224                height,
1225                family,
1226                letter_spacing,
1227                clip,
1228                bounds,
1229                owner,
1230            } => Primitive::Text {
1231                position,
1232                text,
1233                size,
1234                color: color.fade(opacity),
1235                weight,
1236                italic,
1237                max_width,
1238                soft_wrap,
1239                align,
1240                decoration,
1241                decoration_color: decoration_color.map(|c| c.fade(opacity)),
1242                height,
1243                family,
1244                letter_spacing,
1245                clip,
1246                bounds,
1247                owner,
1248            },
1249            Primitive::RichText {
1250                position,
1251                mut runs,
1252                max_width,
1253                soft_wrap,
1254                align,
1255                clip,
1256                bounds,
1257                owner,
1258            } => {
1259                for run in &mut runs {
1260                    run.color = run.color.fade(opacity);
1261                    run.decoration_color = run.decoration_color.map(|c| c.fade(opacity));
1262                }
1263                Primitive::RichText {
1264                    position,
1265                    runs,
1266                    max_width,
1267                    soft_wrap,
1268                    align,
1269                    clip,
1270                    bounds,
1271                    owner,
1272                }
1273            }
1274            Primitive::Path {
1275                path,
1276                fill,
1277                gradient,
1278                stroke,
1279                clip,
1280                owner,
1281            } => Primitive::Path {
1282                path,
1283                fill: fill.map(|c| c.fade(opacity)),
1284                gradient: gradient.map(|g| g.faded(opacity)),
1285                stroke: stroke.map(|s| Stroke::new(s.color.fade(opacity), s.width)),
1286                clip,
1287                owner,
1288            },
1289            Primitive::Image {
1290                image,
1291                rect,
1292                uv,
1293                tint,
1294                strict,
1295                clip,
1296                owner,
1297            } => Primitive::Image {
1298                image,
1299                rect,
1300                uv,
1301                tint: tint.fade(opacity),
1302                strict,
1303                clip,
1304                owner,
1305            },
1306            Primitive::Layer {
1307                primitives,
1308                opacity: group,
1309                clip,
1310                clip_shape,
1311                transform,
1312                filter,
1313                owner,
1314            } => Primitive::Layer {
1315                primitives,
1316                // Fading a layer means dimming its group opacity.
1317                opacity: group * opacity,
1318                clip,
1319                clip_shape,
1320                transform,
1321                filter,
1322                owner,
1323            },
1324        };
1325        self.primitives.push(faded);
1326    }
1327
1328    /// Adds a solid rectangle, with square corners and no border.
1329    pub fn fill_rect(&mut self, rect: Rect, color: Color) {
1330        self.primitives.push(Primitive::Rect {
1331            rect,
1332            color,
1333            color2: color,
1334            gradient_dir: [0.0, 0.0],
1335            radius: BorderRadius::ZERO,
1336            border_width: 0.0,
1337            border_color: Color::TRANSPARENT,
1338            blur: 0.0,
1339            clip: self.current_clip,
1340            owner: self.current_owner,
1341            shader: None,
1342            blur_style: crate::BlurStyle::Normal,
1343        });
1344    }
1345
1346    /// Adds a rectangle with rounded corners (uniform through `f32`, or per corner
1347    /// through [`BorderRadius`]) and/or a border.
1348    /// **Fills a shape**, and draws its edge if it has one.
1349    ///
1350    /// Three of the four shapes are rounded rectangles once the box is known, so they go
1351    /// down the same path as [`Scene::draw_rect`] and cost nothing extra; a bevel becomes
1352    /// a filled path plus a stroked one. A caller who does this by hand at each site gets
1353    /// the fast path wrong somewhere, which is the whole reason this is one function.
1354    pub fn draw_shape(&mut self, rect: Rect, shape: ShapeBorder, fill: Color) {
1355        let side = shape.side();
1356        if let Some((box_, radius)) = shape.as_rounded(rect) {
1357            self.draw_rect(
1358                box_,
1359                fill,
1360                radius,
1361                if side.is_drawn() { side.width } else { 0.0 },
1362                side.color,
1363            );
1364            return;
1365        }
1366        let outline = shape.outline(rect);
1367        self.fill_path(&outline, fill);
1368        if side.is_drawn() {
1369            self.stroke_path(&outline, side.color, side.width);
1370        }
1371    }
1372
1373    /// Adds a flat-coloured rectangle, with an optional border.
1374    pub fn draw_rect(
1375        &mut self,
1376        rect: Rect,
1377        color: Color,
1378        radius: impl Into<BorderRadius>,
1379        border_width: f32,
1380        border_color: Color,
1381    ) {
1382        self.primitives.push(Primitive::Rect {
1383            rect,
1384            color,
1385            color2: color,
1386            gradient_dir: [0.0, 0.0],
1387            radius: radius.into(),
1388            border_width,
1389            border_color,
1390            blur: 0.0,
1391            clip: self.current_clip,
1392            owner: self.current_owner,
1393            shader: None,
1394            blur_style: crate::BlurStyle::Normal,
1395        });
1396    }
1397
1398    /// **A rectangle filled with a gradient** (milestone 644): any of the reference's
1399    /// three, relative to `rect`, with corners and a border as [`Scene::draw_rect`] has
1400    /// them.
1401    #[allow(clippy::too_many_arguments)]
1402    pub fn shaded_rect(
1403        &mut self,
1404        rect: Rect,
1405        gradient: crate::Gradient,
1406        opacity: f32,
1407        radius: impl Into<BorderRadius>,
1408        border_width: f32,
1409        border_color: Color,
1410    ) {
1411        let gradient = if opacity < 1.0 {
1412            gradient.scale(opacity)
1413        } else {
1414            gradient
1415        };
1416        let first = gradient
1417            .colors()
1418            .first()
1419            .copied()
1420            .unwrap_or(Color::TRANSPARENT);
1421        let last = gradient
1422            .colors()
1423            .last()
1424            .copied()
1425            .unwrap_or(Color::TRANSPARENT);
1426        self.primitives.push(Primitive::Rect {
1427            rect,
1428            color: first,
1429            color2: last,
1430            gradient_dir: [0.0, 0.0],
1431            radius: radius.into(),
1432            border_width,
1433            border_color,
1434            blur: 0.0,
1435            clip: self.current_clip,
1436            owner: self.current_owner,
1437            shader: Some(std::sync::Arc::new(gradient)),
1438            blur_style: crate::BlurStyle::Normal,
1439        });
1440    }
1441
1442    /// Adds a linear-gradient rectangle (`color` → `color2` along `dir`).
1443    // Eight, and every one of them a property of the same rectangle. Grouping them
1444    // into a struct is an API change with call sites, not a lint fix.
1445    #[allow(clippy::too_many_arguments)]
1446    pub fn gradient_rect(
1447        &mut self,
1448        rect: Rect,
1449        color: Color,
1450        color2: Color,
1451        dir: [f32; 2],
1452        radius: impl Into<BorderRadius>,
1453        border_width: f32,
1454        border_color: Color,
1455    ) {
1456        self.primitives.push(Primitive::Rect {
1457            rect,
1458            color,
1459            color2,
1460            gradient_dir: dir,
1461            radius: radius.into(),
1462            border_width,
1463            border_color,
1464            blur: 0.0,
1465            clip: self.current_clip,
1466            owner: self.current_owner,
1467            shader: None,
1468            blur_style: crate::BlurStyle::Normal,
1469        });
1470    }
1471
1472    /// Adds a soft shadow — a rounded rectangle with a blurred edge, no border.
1473    pub fn shadow(&mut self, rect: Rect, color: Color, radius: impl Into<BorderRadius>, blur: f32) {
1474        self.styled_shadow(rect, color, radius, blur, BlurStyle::Normal);
1475    }
1476
1477    /// Adds a soft shadow whose blur keeps one side of the shape's edge, or both
1478    /// ([`BlurStyle`]). As [`Self::shadow`], `rect` already reaches `blur` beyond the
1479    /// shape, which runs `blur` inside it.
1480    pub fn styled_shadow(
1481        &mut self,
1482        rect: Rect,
1483        color: Color,
1484        radius: impl Into<BorderRadius>,
1485        blur: f32,
1486        blur_style: BlurStyle,
1487    ) {
1488        self.primitives.push(Primitive::Rect {
1489            rect,
1490            color,
1491            color2: color,
1492            gradient_dir: [0.0, 0.0],
1493            radius: radius.into(),
1494            border_width: 0.0,
1495            border_color: Color::TRANSPARENT,
1496            blur,
1497            clip: self.current_clip,
1498            owner: self.current_owner,
1499            shader: None,
1500            blur_style,
1501        });
1502    }
1503
1504    /// Fills a vector path with a flat colour, using the *non-zero* rule.
1505    pub fn fill_path(&mut self, path: &Path, color: Color) {
1506        self.primitives.push(Primitive::Path {
1507            path: path.clone(),
1508            fill: Some(color),
1509            gradient: None,
1510            stroke: None,
1511            clip: self.current_clip,
1512            owner: self.current_owner,
1513        });
1514    }
1515
1516    /// Fills a path with a **linear gradient**: `from_color` at `from`, `to_color` at
1517    /// `to`, clamped outside that span.
1518    ///
1519    /// The two points are in the path's own space rather than relative to its box, so
1520    /// the fade can be aimed at the thing it is about — the edge a glow springs from,
1521    /// the depth of a band — which is what an ellipse cap needs, its bounding box
1522    /// being mostly off screen.
1523    pub fn fill_path_gradient(
1524        &mut self,
1525        path: &Path,
1526        from_color: Color,
1527        to_color: Color,
1528        from: Point,
1529        to: Point,
1530    ) {
1531        self.primitives.push(Primitive::Path {
1532            path: path.clone(),
1533            fill: Some(from_color),
1534            gradient: Some(PathGradient::Linear { to_color, from, to }),
1535            stroke: None,
1536            clip: self.current_clip,
1537            owner: self.current_owner,
1538        });
1539    }
1540
1541    /// Fills a path with a **radial gradient**: `from_color` on the ellipse of radii
1542    /// `inner × radii` about `center`, `to_color` on the ellipse of radii `radii`.
1543    ///
1544    /// This is what a shape with a **curved** boundary needs in order to fade to
1545    /// nothing all the way round: a straight fade reaches zero on a line, so it still
1546    /// leaves an edge wherever the shape turns away from that line.
1547    pub fn fill_path_radial(
1548        &mut self,
1549        path: &Path,
1550        from_color: Color,
1551        to_color: Color,
1552        center: Point,
1553        radii: Size,
1554        inner: f32,
1555    ) {
1556        self.primitives.push(Primitive::Path {
1557            path: path.clone(),
1558            fill: Some(from_color),
1559            gradient: Some(PathGradient::Radial {
1560                to_color,
1561                center,
1562                radii,
1563                inner,
1564            }),
1565            stroke: None,
1566            clip: self.current_clip,
1567            owner: self.current_owner,
1568        });
1569    }
1570
1571    /// Fills a path with a [`GradientRamp`] — any number of stops, along a line or between
1572    /// two circles, turned or skewed by its own transform.
1573    pub fn fill_path_ramp(&mut self, path: &Path, ramp: GradientRamp, opacity: f32) {
1574        self.primitives.push(Primitive::Path {
1575            path: path.clone(),
1576            fill: Some(Color::WHITE.fade(opacity)),
1577            gradient: Some(PathGradient::Ramp(Arc::new(ramp))),
1578            stroke: None,
1579            clip: self.current_clip,
1580            owner: self.current_owner,
1581        });
1582    }
1583
1584    /// Strokes a path's outline (colour plus width), with no fill.
1585    pub fn stroke_path(&mut self, path: &Path, color: Color, width: f32) {
1586        self.primitives.push(Primitive::Path {
1587            path: path.clone(),
1588            fill: None,
1589            gradient: None,
1590            stroke: Some(Stroke::new(color, width)),
1591            clip: self.current_clip,
1592            owner: self.current_owner,
1593        });
1594    }
1595
1596    /// Adds a path filled **and/or** stroked — both passes in one primitive.
1597    pub fn paint_path(&mut self, path: &Path, fill: Option<Color>, stroke: Option<Stroke>) {
1598        self.primitives.push(Primitive::Path {
1599            path: path.clone(),
1600            fill,
1601            gradient: None,
1602            stroke,
1603            clip: self.current_clip,
1604            owner: self.current_owner,
1605        });
1606    }
1607
1608    /// Draws an image into `rect`, sampling the `uv` sub-region (in `0..1`) and
1609    /// tinting it by `tint` (white = unchanged). Low level: see [`Scene::image`]
1610    /// for automatic fitting through [`crate::BoxFit`].
1611    pub fn draw_image(&mut self, image: &ImageHandle, rect: Rect, uv: Rect, tint: Color) {
1612        self.push_image(image, rect, uv, tint, false);
1613    }
1614
1615    /// [`Scene::draw_image`], sampled **strictly** inside `uv`: no filtered sample
1616    /// reaches the texels around it. For one part of a picture stretched apart from the
1617    /// rest, as a centre slice's are.
1618    pub fn draw_image_strict(&mut self, image: &ImageHandle, rect: Rect, uv: Rect, tint: Color) {
1619        self.push_image(image, rect, uv, tint, true);
1620    }
1621
1622    fn push_image(&mut self, image: &ImageHandle, rect: Rect, uv: Rect, tint: Color, strict: bool) {
1623        self.primitives.push(Primitive::Image {
1624            image: image.clone(),
1625            rect,
1626            uv,
1627            tint,
1628            strict,
1629            clip: self.current_clip,
1630            owner: self.current_owner,
1631        });
1632    }
1633
1634    /// Draws an image fitted into `rect` per `fit` (aspect preserved, letterboxed
1635    /// or cropped), untinted.
1636    pub fn image(&mut self, image: &ImageHandle, rect: Rect, fit: crate::BoxFit) {
1637        let (dst, uv) = fit.apply(image.size(), rect);
1638        self.draw_image(image, dst, uv, Color::WHITE);
1639    }
1640
1641    /// Composes a **layer**: `build` fills a subgroup of primitives, which is then
1642    /// composited **as one** at `opacity` (`0..1`). Unlike an opacity applied
1643    /// primitive by primitive, the group alpha stays correct where the inner
1644    /// primitives overlap. The layer inherits the current clip and owner.
1645    pub fn layer(&mut self, opacity: f32, build: impl FnOnce(&mut Scene)) {
1646        let mut inner = Scene::new();
1647        inner.current_clip = self.current_clip;
1648        inner.current_owner = self.current_owner;
1649        build(&mut inner);
1650        self.primitives.push(Primitive::Layer {
1651            primitives: inner.primitives,
1652            opacity,
1653            clip: self.current_clip,
1654            clip_shape: ClipShape::Rect,
1655            transform: None,
1656            filter: LayerFilter::NONE,
1657            owner: self.current_owner,
1658        });
1659    }
1660
1661    /// Composes a **transformed layer**: `build` fills a subgroup of primitives, which is
1662    /// rendered flat and then composited through `transform`.
1663    ///
1664    /// The transform belongs to the group and not to the primitives inside it, which is
1665    /// the whole point: a rotation applies to a rectangle, a line of text and an image at
1666    /// once, and none of them has to know how to rotate itself.
1667    ///
1668    /// **Paint the group where it fits, not where it lands.** The renderer draws the
1669    /// group flat into a *window-sized* texture and composites that through the
1670    /// transform, so anything laid out flat beyond the window is gone before the
1671    /// transform could have brought it back inside. A group that ends up near an edge
1672    /// should be built at the origin and carried there by the transform.
1673    pub fn transformed(&mut self, transform: LayerTransform, build: impl FnOnce(&mut Scene)) {
1674        let mut inner = Scene::new();
1675        inner.current_clip = self.current_clip;
1676        inner.current_owner = self.current_owner;
1677        inner.current_bounds = self.current_bounds;
1678        build(&mut inner);
1679        self.primitives.push(Primitive::Layer {
1680            primitives: inner.primitives,
1681            opacity: 1.0,
1682            clip: self.current_clip,
1683            clip_shape: ClipShape::Rect,
1684            transform: Some(transform),
1685            filter: LayerFilter::NONE,
1686            owner: self.current_owner,
1687        });
1688    }
1689
1690    /// Adds a **masked group**: everything `build` emits is composited as one, with
1691    /// `mask` multiplied into it, so the shader's alpha becomes the group's transparency.
1692    ///
1693    /// A fade has to be a group. Applied primitive by primitive it would fade each of
1694    /// them against the background separately, and where two of them overlap the result
1695    /// is neither one nor the other.
1696    pub fn masked(&mut self, mask: ShaderMask, build: impl FnOnce(&mut Scene)) {
1697        let mut inner = Scene::new();
1698        inner.current_clip = self.current_clip;
1699        inner.current_owner = self.current_owner;
1700        inner.current_bounds = self.current_bounds;
1701        build(&mut inner);
1702        self.primitives.push(Primitive::Layer {
1703            primitives: inner.primitives,
1704            opacity: 1.0,
1705            clip: self.current_clip,
1706            clip_shape: ClipShape::Rect,
1707            transform: None,
1708            filter: LayerFilter {
1709                mask: Some(mask),
1710                ..LayerFilter::NONE
1711            },
1712            owner: self.current_owner,
1713        });
1714    }
1715
1716    /// Adds a **blended group** (milestone 646): everything `build` emits is composited
1717    /// as one, meeting what is already painted underneath by `mode` rather than being
1718    /// painted over it. The group's clip is where it applies; outside it, what was
1719    /// there stays.
1720    pub fn blended(&mut self, mode: crate::BlendMode, build: impl FnOnce(&mut Scene)) {
1721        self.blended_in(mode, ClipShape::Rect, build);
1722    }
1723
1724    /// [`Scene::blended`] in a `shape` inscribed in the current clip: rounded corners or
1725    /// an ellipse, or a path in absolute coordinates.
1726    pub fn blended_in(
1727        &mut self,
1728        mode: crate::BlendMode,
1729        shape: ClipShape,
1730        build: impl FnOnce(&mut Scene),
1731    ) {
1732        let mut inner = Scene::new();
1733        inner.current_clip = self.current_clip;
1734        inner.current_owner = self.current_owner;
1735        inner.current_bounds = self.current_bounds;
1736        build(&mut inner);
1737        self.primitives.push(Primitive::Layer {
1738            primitives: inner.primitives,
1739            opacity: 1.0,
1740            clip: self.current_clip,
1741            clip_shape: shape,
1742            transform: None,
1743            filter: LayerFilter {
1744                blend: Some(mode),
1745                ..LayerFilter::NONE
1746            },
1747            owner: self.current_owner,
1748        });
1749    }
1750
1751    /// Adds **rich** text: resolved runs, mixing styles and colours, laid out as one
1752    /// piece, anchored by its top-left corner.
1753    pub fn rich_text(&mut self, position: Point, runs: Vec<TextRun>) {
1754        self.primitives.push(Primitive::RichText {
1755            position,
1756            runs,
1757            max_width: None,
1758            soft_wrap: true,
1759            align: TextAlign::Start,
1760            clip: self.current_clip,
1761            bounds: self.current_bounds,
1762            owner: self.current_owner,
1763        });
1764    }
1765
1766    /// Adds a **rich paragraph**: the runs wrap beyond `max_width`, and the render's
1767    /// wrapping matches the layout's.
1768    pub fn rich_text_wrapped(&mut self, position: Point, runs: Vec<TextRun>, max_width: f32) {
1769        self.primitives.push(Primitive::RichText {
1770            position,
1771            runs,
1772            max_width: Some(max_width),
1773            soft_wrap: true,
1774            align: TextAlign::Start,
1775            clip: self.current_clip,
1776            bounds: self.current_bounds,
1777            owner: self.current_owner,
1778        });
1779    }
1780
1781    /// Adds a line of text, anchored by its top-left corner — size, weight and italics
1782    /// from the [`ResolvedTextStyle`].
1783    ///
1784    /// It takes a **resolved** style and not a [`TextStyle`](crate::TextStyle) because
1785    /// this is the bottom of the chain: a shaper needs a number and there is nobody left
1786    /// below to ask one of. `color` is settled the same way, by the caller, usually
1787    /// against the theme — which is the one answer this crate cannot work out for itself.
1788    ///
1789    /// There is deliberately **no form of this that takes a bare `f32` size**. A number
1790    /// arriving here has been through [`TextStyle::resolved`](crate::TextStyle::resolved),
1791    /// which is the single place the reader's font size is applied; a second door taking a
1792    /// raw constant is a door onto text that ignores the reader, and forty-seven call sites
1793    /// walked through it (milestone 406).
1794    pub fn text(
1795        &mut self,
1796        position: Point,
1797        text: impl Into<String>,
1798        style: &ResolvedTextStyle,
1799        color: Color,
1800    ) {
1801        self.primitives.push(Primitive::Text {
1802            position,
1803            text: text.into(),
1804            size: style.size,
1805            color,
1806            weight: style.weight,
1807            italic: style.italic,
1808            max_width: None,
1809            soft_wrap: true,
1810            align: TextAlign::Start,
1811            decoration: style.decoration,
1812            decoration_color: style.decoration_color,
1813            height: style.height,
1814            family: style.family,
1815            letter_spacing: style.letter_spacing,
1816            clip: self.current_clip,
1817            bounds: self.current_bounds,
1818            owner: self.current_owner,
1819        });
1820    }
1821
1822    /// Adds a **paragraph**: styled text that wraps beyond `max_width`, the render's
1823    /// wrapping matching the layout's.
1824    pub fn text_wrapped(
1825        &mut self,
1826        position: Point,
1827        text: impl Into<String>,
1828        style: &ResolvedTextStyle,
1829        color: Color,
1830        max_width: f32,
1831    ) {
1832        self.primitives.push(Primitive::Text {
1833            position,
1834            text: text.into(),
1835            size: style.size,
1836            color,
1837            weight: style.weight,
1838            italic: style.italic,
1839            max_width: Some(max_width),
1840            soft_wrap: true,
1841            align: TextAlign::Start,
1842            decoration: style.decoration,
1843            decoration_color: style.decoration_color,
1844            height: style.height,
1845            family: style.family,
1846            letter_spacing: style.letter_spacing,
1847            clip: self.current_clip,
1848            bounds: self.current_bounds,
1849            owner: self.current_owner,
1850        });
1851    }
1852
1853    /// Adds a **block** of styled text: a box of a known width, which the text may be
1854    /// wrapped in and is aligned inside.
1855    ///
1856    /// The three settings travel together because they are one decision. A width with no
1857    /// wrapping is a line that knows where its box ends and runs past it anyway — which is
1858    /// what a clipped or ellipsised line is — and an alignment with no width has nothing
1859    /// to align against.
1860    pub fn text_block(
1861        &mut self,
1862        position: Point,
1863        text: impl Into<String>,
1864        style: &ResolvedTextStyle,
1865        color: Color,
1866        block: TextBlock,
1867    ) {
1868        self.primitives.push(Primitive::Text {
1869            position,
1870            text: text.into(),
1871            size: style.size,
1872            color,
1873            weight: style.weight,
1874            italic: style.italic,
1875            max_width: block.width,
1876            soft_wrap: block.soft_wrap,
1877            align: block.align,
1878            decoration: style.decoration,
1879            decoration_color: style.decoration_color,
1880            height: style.height,
1881            family: style.family,
1882            letter_spacing: style.letter_spacing,
1883            clip: self.current_clip,
1884            bounds: self.current_bounds,
1885            owner: self.current_owner,
1886        });
1887    }
1888
1889    /// Adds a **block** of rich text: resolved runs in a box of a known width, which they
1890    /// may be wrapped in and are aligned inside. The rich counterpart of
1891    /// [`Scene::text_block`].
1892    pub fn rich_text_block(&mut self, position: Point, runs: Vec<TextRun>, block: TextBlock) {
1893        self.primitives.push(Primitive::RichText {
1894            position,
1895            runs,
1896            max_width: block.width,
1897            soft_wrap: block.soft_wrap,
1898            align: block.align,
1899            clip: self.current_clip,
1900            bounds: self.current_bounds,
1901            owner: self.current_owner,
1902        });
1903    }
1904
1905    /// The number of primitives in the scene.
1906    pub fn len(&self) -> usize {
1907        self.primitives.len()
1908    }
1909
1910    /// `true` when the scene holds no primitives at all.
1911    pub fn is_empty(&self) -> bool {
1912        self.primitives.is_empty()
1913    }
1914
1915    /// The primitives, in insertion order, which is drawing order.
1916    pub fn primitives(&self) -> &[Primitive] {
1917        &self.primitives
1918    }
1919
1920    /// A copy of the scene with all geometry scaled by `factor` — the logical →
1921    /// physical conversion for HiDPI rendering.
1922    pub fn scaled(&self, factor: f32) -> Scene {
1923        Scene {
1924            primitives: self.primitives.iter().map(|p| p.scaled(factor)).collect(),
1925            current_clip: self.current_clip.scale(factor),
1926            current_owner: self.current_owner,
1927            current_bounds: self.current_bounds.scale(factor),
1928        }
1929    }
1930}
1931
1932#[cfg(test)]
1933mod tests {
1934    use super::*;
1935
1936    fn close(a: Option<f32>, b: f32) -> bool {
1937        a.is_some_and(|a| (a - b).abs() < 1e-4)
1938    }
1939
1940    fn ramp(shape: RampShape, tile: TileMode) -> GradientRamp {
1941        GradientRamp {
1942            shape,
1943            stops: vec![
1944                (0.0, Color::rgb(1.0, 0.0, 0.0)),
1945                (0.5, Color::rgb(0.0, 1.0, 0.0)),
1946                (1.0, Color::rgb(0.0, 0.0, 1.0)),
1947            ],
1948            tile,
1949            transform: Affine::default(),
1950        }
1951    }
1952
1953    const LINE: RampShape = RampShape::Linear {
1954        from: Point::new(10.0, 0.0),
1955        to: Point::new(30.0, 0.0),
1956    };
1957
1958    /// **Along a line**: the offset is the distance along it, the same across it, and the
1959    /// ends do what the tile mode says.
1960    #[test]
1961    fn a_linear_ramp_runs_along_its_line_and_tiles_past_it() {
1962        let clamp = ramp(LINE, TileMode::Clamp);
1963        assert!(close(clamp.t_at(Point::new(15.0, 0.0)), 0.25));
1964        assert!(
1965            close(clamp.t_at(Point::new(15.0, 99.0)), 0.25),
1966            "constant across"
1967        );
1968        assert!(close(clamp.t_at(Point::new(50.0, 0.0)), 1.0));
1969        let repeat = ramp(LINE, TileMode::Repeated);
1970        assert!(close(repeat.t_at(Point::new(35.0, 0.0)), 0.25));
1971        let mirror = ramp(LINE, TileMode::Mirror);
1972        assert!(close(mirror.t_at(Point::new(35.0, 0.0)), 0.75));
1973        let decal = ramp(LINE, TileMode::Decal);
1974        assert_eq!(
1975            decal.t_at(Point::new(35.0, 0.0)),
1976            None,
1977            "nothing past a decal's end"
1978        );
1979        assert_eq!(decal.color_at(Point::new(35.0, 0.0)), Color::TRANSPARENT);
1980    }
1981
1982    /// **Between the stops**, the mix of the two either side; before and after, the end
1983    /// colours.
1984    #[test]
1985    fn a_ramp_mixes_the_stops_either_side() {
1986        let r = ramp(LINE, TileMode::Clamp);
1987        assert_eq!(r.sample(-1.0), Color::rgb(1.0, 0.0, 0.0));
1988        assert_eq!(r.sample(0.5), Color::rgb(0.0, 1.0, 0.0));
1989        let quarter = r.sample(0.25);
1990        assert!((quarter.r - 0.5).abs() < 1e-6 && (quarter.g - 0.5).abs() < 1e-6);
1991        assert_eq!(r.sample(2.0), Color::rgb(0.0, 0.0, 1.0));
1992    }
1993
1994    /// **Radial**: the distance from the centre in radii, and, with a focal point off the
1995    /// centre, the circle through the point — nearer on the focal side.
1996    #[test]
1997    fn a_radial_ramp_runs_outward_and_bends_towards_its_focal_point() {
1998        let centred = ramp(
1999            RampShape::Radial {
2000                center: Point::new(0.0, 0.0),
2001                radius: 10.0,
2002                focal: Point::new(0.0, 0.0),
2003                focal_radius: 0.0,
2004            },
2005            TileMode::Clamp,
2006        );
2007        assert!(close(centred.t_at(Point::new(5.0, 0.0)), 0.5));
2008        assert!(
2009            close(centred.t_at(Point::new(0.0, -5.0)), 0.5),
2010            "the same all round"
2011        );
2012        let focal = ramp(
2013            RampShape::Radial {
2014                center: Point::new(0.0, 0.0),
2015                radius: 10.0,
2016                focal: Point::new(5.0, 0.0),
2017                focal_radius: 0.0,
2018            },
2019            TileMode::Clamp,
2020        );
2021        // At the focal point, the start; on the outer circle, the end, either side.
2022        assert!(close(focal.t_at(Point::new(5.0, 0.0)), 0.0));
2023        assert!(close(focal.t_at(Point::new(10.0, 0.0)), 1.0));
2024        assert!(close(focal.t_at(Point::new(-10.0, 0.0)), 1.0));
2025        // Halfway from the focal point to each side's edge is halfway through.
2026        assert!(close(focal.t_at(Point::new(7.5, 0.0)), 0.5));
2027        assert!(close(focal.t_at(Point::new(-2.5, 0.0)), 0.5));
2028    }
2029
2030    /// **A transformed gradient is drawn exactly**: the point is mapped back into the
2031    /// gradient's own space, so a turned line runs the turned way.
2032    #[test]
2033    fn a_ramp_follows_its_transform() {
2034        let mut turned = ramp(LINE, TileMode::Clamp);
2035        // A quarter turn about the origin: the line now runs down the y axis.
2036        turned.transform = Affine::rotation(std::f32::consts::FRAC_PI_2);
2037        assert!(close(turned.t_at(Point::new(0.0, 15.0)), 0.25));
2038        assert!(
2039            close(turned.t_at(Point::new(40.0, 15.0)), 0.25),
2040            "constant across it"
2041        );
2042    }
2043
2044    /// **A scaled or moved path takes its gradient with it**: the colour at a point of the
2045    /// moved shape is the colour the same point had before.
2046    #[test]
2047    fn a_ramp_moves_and_scales_with_its_primitive() {
2048        let mut scene = Scene::new();
2049        let shape = Path::rect(Rect::new(10.0, 0.0, 20.0, 10.0));
2050        scene.fill_path_ramp(&shape, ramp(LINE, TileMode::Clamp), 1.0);
2051        let gradient_of = |p: &Primitive| match p {
2052            Primitive::Path {
2053                gradient: Some(PathGradient::Ramp(r)),
2054                ..
2055            } => r.clone(),
2056            _ => panic!("a ramp"),
2057        };
2058        let before = gradient_of(&scene.primitives()[0]);
2059        let scaled = gradient_of(&scene.primitives()[0].scaled_xy(2.0, 3.0));
2060        assert!(close(
2061            scaled.t_at(Point::new(30.0, 6.0)),
2062            before.t_at(Point::new(15.0, 2.0)).unwrap()
2063        ));
2064        let moved = gradient_of(&scene.primitives()[0].translated(5.0, 7.0));
2065        assert!(close(moved.t_at(Point::new(20.0, 7.0)), 0.25));
2066    }
2067    use crate::{Color, Rect};
2068
2069    #[test]
2070    fn fill_rect_pushes_primitive_with_current_clip() {
2071        let mut scene = Scene::new();
2072        assert!(scene.is_empty());
2073        scene.fill_rect(Rect::new(1.0, 2.0, 3.0, 4.0), Color::WHITE);
2074        assert_eq!(scene.len(), 1);
2075        assert_eq!(
2076            scene.primitives()[0],
2077            Primitive::Rect {
2078                rect: Rect::new(1.0, 2.0, 3.0, 4.0),
2079                color: Color::WHITE,
2080                color2: Color::WHITE,
2081                gradient_dir: [0.0, 0.0],
2082                radius: BorderRadius::ZERO,
2083                border_width: 0.0,
2084                border_color: Color::TRANSPARENT,
2085                blur: 0.0,
2086                clip: Rect::UNBOUNDED,
2087                owner: 0,
2088                shader: None,
2089                blur_style: crate::BlurStyle::Normal,
2090            }
2091        );
2092    }
2093
2094    /// A gradient describes geometry, so it has to move with the geometry it
2095    /// describes — otherwise a fade aimed at the top of a list would stay behind when
2096    /// the DPI scale or a layer offset moved the shape.
2097    #[test]
2098    fn a_radial_gradient_travels_with_its_shape() {
2099        let mut scene = Scene::new();
2100        scene.fill_path_radial(
2101            &Path::oval(Rect::new(0.0, 0.0, 40.0, 20.0)),
2102            Color::WHITE,
2103            Color::TRANSPARENT,
2104            Point::new(20.0, 10.0),
2105            Size::new(20.0, 10.0),
2106            0.8,
2107        );
2108        let moved = scene.primitives()[0].clone().scaled_xy(2.0, 3.0);
2109        let Primitive::Path { gradient, .. } = moved.translated(5.0, 7.0) else {
2110            panic!("a filled path");
2111        };
2112        assert_eq!(
2113            gradient,
2114            Some(PathGradient::Radial {
2115                to_color: Color::TRANSPARENT,
2116                center: Point::new(45.0, 37.0),
2117                radii: Size::new(40.0, 30.0),
2118                // The rind is a ratio, so it is the one thing scaling must not touch.
2119                inner: 0.8,
2120            })
2121        );
2122        assert_eq!(gradient.unwrap().faded(0.5).to_color().a, 0.0);
2123    }
2124
2125    #[test]
2126    fn push_faded_scales_alpha_and_keeps_owner() {
2127        let mut scene = Scene::new();
2128        scene.set_owner(42);
2129        scene.fill_rect(
2130            Rect::new(0.0, 0.0, 1.0, 1.0),
2131            Color::rgba(1.0, 0.0, 0.0, 1.0),
2132        );
2133        let source = scene.primitives()[0].clone();
2134        assert_eq!(source.owner(), 42);
2135
2136        let mut target = Scene::new();
2137        target.push_faded(&source, 0.5);
2138        if let Primitive::Rect { color, owner, .. } = target.primitives()[0] {
2139            assert_eq!(color.a, 0.5);
2140            assert_eq!(owner, 42);
2141        } else {
2142            panic!("expected a rect");
2143        }
2144    }
2145
2146    #[test]
2147    fn scaled_multiplies_geometry_not_colors() {
2148        let mut scene = Scene::new();
2149        scene.draw_rect(
2150            Rect::new(2.0, 4.0, 10.0, 20.0),
2151            Color::rgb(1.0, 0.0, 0.0),
2152            3.0,
2153            1.0,
2154            Color::WHITE,
2155        );
2156        scene.text(
2157            Point::new(5.0, 6.0),
2158            "hi",
2159            &ResolvedTextStyle::exact(18.0),
2160            Color::BLACK,
2161        );
2162
2163        let big = scene.scaled(2.0);
2164        match &big.primitives()[0] {
2165            Primitive::Rect {
2166                rect,
2167                radius,
2168                border_width,
2169                color,
2170                ..
2171            } => {
2172                assert_eq!(*rect, Rect::new(4.0, 8.0, 20.0, 40.0));
2173                assert_eq!(*radius, BorderRadius::uniform(6.0));
2174                assert_eq!(*border_width, 2.0);
2175                assert_eq!(*color, Color::rgb(1.0, 0.0, 0.0)); // colour unchanged
2176            }
2177            _ => panic!("expected a rect"),
2178        }
2179        match &big.primitives()[1] {
2180            Primitive::Text {
2181                position,
2182                size,
2183                text,
2184                ..
2185            } => {
2186                assert_eq!(*position, Point::new(10.0, 12.0));
2187                assert_eq!(*size, 36.0);
2188                assert_eq!(text, "hi"); // text unchanged
2189            }
2190            _ => panic!("expected text"),
2191        }
2192    }
2193
2194    #[test]
2195    fn layer_captures_subprimitives_and_opacity() {
2196        let mut scene = Scene::new();
2197        scene.set_owner(7);
2198        scene.set_clip(Rect::new(0.0, 0.0, 50.0, 50.0));
2199        scene.layer(0.5, |inner| {
2200            inner.fill_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Color::WHITE);
2201            inner.fill_rect(Rect::new(5.0, 5.0, 10.0, 10.0), Color::BLACK);
2202        });
2203        assert_eq!(scene.len(), 1);
2204        match &scene.primitives()[0] {
2205            Primitive::Layer {
2206                primitives,
2207                opacity,
2208                clip,
2209                owner,
2210                ..
2211            } => {
2212                assert_eq!(primitives.len(), 2);
2213                assert_eq!(*opacity, 0.5);
2214                assert_eq!(*clip, Rect::new(0.0, 0.0, 50.0, 50.0));
2215                assert_eq!(*owner, 7);
2216            }
2217            _ => panic!("expected a layer"),
2218        }
2219    }
2220
2221    #[test]
2222    fn fading_a_layer_scales_its_group_opacity() {
2223        let mut scene = Scene::new();
2224        scene.layer(0.8, |inner| {
2225            inner.fill_rect(Rect::new(0.0, 0.0, 1.0, 1.0), Color::WHITE)
2226        });
2227        let layer = scene.primitives()[0].clone();
2228        let mut target = Scene::new();
2229        target.push_faded(&layer, 0.5);
2230        match &target.primitives()[0] {
2231            Primitive::Layer { opacity, .. } => assert!((*opacity - 0.4).abs() < 1e-6),
2232            _ => panic!("expected a layer"),
2233        }
2234    }
2235
2236    #[test]
2237    fn scaling_a_layer_scales_its_children() {
2238        let mut scene = Scene::new();
2239        scene.layer(1.0, |inner| {
2240            inner.fill_rect(Rect::new(2.0, 3.0, 4.0, 5.0), Color::WHITE)
2241        });
2242        let big = scene.scaled(2.0);
2243        match &big.primitives()[0] {
2244            Primitive::Layer { primitives, .. } => match &primitives[0] {
2245                Primitive::Rect { rect, .. } => assert_eq!(*rect, Rect::new(4.0, 6.0, 8.0, 10.0)),
2246                _ => panic!("expected a rect"),
2247            },
2248            _ => panic!("expected a layer"),
2249        }
2250    }
2251
2252    #[test]
2253    fn set_clip_applies_to_following_primitives() {
2254        let mut scene = Scene::new();
2255        let clip = Rect::new(0.0, 0.0, 10.0, 10.0);
2256        scene.set_clip(clip);
2257        scene.fill_rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::BLACK);
2258        if let Primitive::Rect { clip: c, .. } = scene.primitives()[0] {
2259            assert_eq!(c, clip);
2260        } else {
2261            panic!("expected a rect");
2262        }
2263    }
2264
2265    /// **A layer moved moves its mask** (milestone 610): a mask's geometry is held in the
2266    /// scene's coordinates once resolved from its box, so a layer drawn somewhere else
2267    /// carries it along. It was left behind, so a moved masked layer was masked where it
2268    /// had been.
2269    #[test]
2270    fn a_moved_layer_carries_its_mask() {
2271        let mask = crate::ShaderMask {
2272            shader: crate::MaskShader::Linear {
2273                from: Point::new(10.0, 0.0),
2274                to: Point::new(110.0, 0.0),
2275                from_color: Color::WHITE,
2276                to_color: Color::TRANSPARENT,
2277            },
2278            blend: crate::BlendMode::Modulate,
2279        };
2280        let layer = Primitive::Layer {
2281            primitives: Vec::new(),
2282            opacity: 1.0,
2283            clip: Rect::new(10.0, 0.0, 100.0, 20.0),
2284            clip_shape: ClipShape::Rect,
2285            transform: None,
2286            filter: crate::LayerFilter {
2287                mask: Some(mask),
2288                ..crate::LayerFilter::NONE
2289            },
2290            owner: 0,
2291        };
2292        let moved = layer.translated(-10.0, 5.0);
2293        let Primitive::Layer { filter, clip, .. } = moved else {
2294            unreachable!()
2295        };
2296        assert_eq!(clip, Rect::new(0.0, 5.0, 100.0, 20.0));
2297        let Some(crate::ShaderMask {
2298            shader: crate::MaskShader::Linear { from, to, .. },
2299            ..
2300        }) = filter.mask
2301        else {
2302            panic!("the mask is kept")
2303        };
2304        assert_eq!((from, to), (Point::new(0.0, 5.0), Point::new(100.0, 5.0)));
2305    }
2306}