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 a whole image **through an affine map** from its pixels — `(0, 0)` to
1635    /// `(width, height)` — to the screen, tinted by `tint`: a bitmap inside vector
1636    /// artwork, which may be turned, slanted or mirrored with it (milestone 694).
1637    ///
1638    /// A map that only scales and moves is one image primitive. Any other draws the image
1639    /// upright at its size on screen, at the origin, in a [transformed
1640    /// layer](Scene::transformed) that turns it into place, so it is sampled once at the
1641    /// resolution it is shown at. Like any transformed layer, an image larger than the
1642    /// window once on screen loses what lies past the window's size.
1643    pub fn draw_image_mapped(&mut self, image: &ImageHandle, to_screen: Affine, tint: Color) {
1644        let (w, h) = (image.width() as f32, image.height() as f32);
1645        let [a, b, c, d, e, f] = to_screen.m;
1646        if w == 0.0 || h == 0.0 || !to_screen.m.iter().all(|v| v.is_finite()) {
1647            return;
1648        }
1649        let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
1650        if b == 0.0 && c == 0.0 && a > 0.0 && d > 0.0 {
1651            self.draw_image(image, Rect::new(e, f, a * w, d * h), whole, tint);
1652            return;
1653        }
1654        // The length each pixel's sides take on screen.
1655        let (sx, sy) = (a.hypot(b), c.hypot(d));
1656        if sx == 0.0 || sy == 0.0 {
1657            return;
1658        }
1659        let flat = Rect::new(0.0, 0.0, w * sx, h * sy);
1660        let turn = Affine::scale(1.0 / sx, 1.0 / sy).then(to_screen);
1661        self.transformed(LayerTransform::new(turn), |scene| {
1662            // The clip around it is the screen's; flat, the image is its own.
1663            scene.set_clip(flat);
1664            scene.draw_image(image, flat, whole, tint);
1665        });
1666    }
1667
1668    /// Draws an image fitted into `rect` per `fit` (aspect preserved, letterboxed
1669    /// or cropped), untinted.
1670    pub fn image(&mut self, image: &ImageHandle, rect: Rect, fit: crate::BoxFit) {
1671        let (dst, uv) = fit.apply(image.size(), rect);
1672        self.draw_image(image, dst, uv, Color::WHITE);
1673    }
1674
1675    /// Composes a **layer**: `build` fills a subgroup of primitives, which is then
1676    /// composited **as one** at `opacity` (`0..1`). Unlike an opacity applied
1677    /// primitive by primitive, the group alpha stays correct where the inner
1678    /// primitives overlap. The layer inherits the current clip and owner.
1679    pub fn layer(&mut self, opacity: f32, build: impl FnOnce(&mut Scene)) {
1680        let mut inner = Scene::new();
1681        inner.current_clip = self.current_clip;
1682        inner.current_owner = self.current_owner;
1683        build(&mut inner);
1684        self.primitives.push(Primitive::Layer {
1685            primitives: inner.primitives,
1686            opacity,
1687            clip: self.current_clip,
1688            clip_shape: ClipShape::Rect,
1689            transform: None,
1690            filter: LayerFilter::NONE,
1691            owner: self.current_owner,
1692        });
1693    }
1694
1695    /// Composes a **transformed layer**: `build` fills a subgroup of primitives, which is
1696    /// rendered flat and then composited through `transform`.
1697    ///
1698    /// The transform belongs to the group and not to the primitives inside it, which is
1699    /// the whole point: a rotation applies to a rectangle, a line of text and an image at
1700    /// once, and none of them has to know how to rotate itself.
1701    ///
1702    /// **Paint the group where it fits, not where it lands.** The renderer draws the
1703    /// group flat into a *window-sized* texture and composites that through the
1704    /// transform, so anything laid out flat beyond the window is gone before the
1705    /// transform could have brought it back inside. A group that ends up near an edge
1706    /// should be built at the origin and carried there by the transform.
1707    pub fn transformed(&mut self, transform: LayerTransform, build: impl FnOnce(&mut Scene)) {
1708        let mut inner = Scene::new();
1709        inner.current_clip = self.current_clip;
1710        inner.current_owner = self.current_owner;
1711        inner.current_bounds = self.current_bounds;
1712        build(&mut inner);
1713        self.primitives.push(Primitive::Layer {
1714            primitives: inner.primitives,
1715            opacity: 1.0,
1716            clip: self.current_clip,
1717            clip_shape: ClipShape::Rect,
1718            transform: Some(transform),
1719            filter: LayerFilter::NONE,
1720            owner: self.current_owner,
1721        });
1722    }
1723
1724    /// Adds a **masked group**: everything `build` emits is composited as one, with
1725    /// `mask` multiplied into it, so the shader's alpha becomes the group's transparency.
1726    ///
1727    /// A fade has to be a group. Applied primitive by primitive it would fade each of
1728    /// them against the background separately, and where two of them overlap the result
1729    /// is neither one nor the other.
1730    pub fn masked(&mut self, mask: ShaderMask, build: impl FnOnce(&mut Scene)) {
1731        let mut inner = Scene::new();
1732        inner.current_clip = self.current_clip;
1733        inner.current_owner = self.current_owner;
1734        inner.current_bounds = self.current_bounds;
1735        build(&mut inner);
1736        self.primitives.push(Primitive::Layer {
1737            primitives: inner.primitives,
1738            opacity: 1.0,
1739            clip: self.current_clip,
1740            clip_shape: ClipShape::Rect,
1741            transform: None,
1742            filter: LayerFilter {
1743                mask: Some(mask),
1744                ..LayerFilter::NONE
1745            },
1746            owner: self.current_owner,
1747        });
1748    }
1749
1750    /// Adds a **blended group** (milestone 646): everything `build` emits is composited
1751    /// as one, meeting what is already painted underneath by `mode` rather than being
1752    /// painted over it. The group's clip is where it applies; outside it, what was
1753    /// there stays.
1754    pub fn blended(&mut self, mode: crate::BlendMode, build: impl FnOnce(&mut Scene)) {
1755        self.blended_in(mode, ClipShape::Rect, build);
1756    }
1757
1758    /// [`Scene::blended`] in a `shape` inscribed in the current clip: rounded corners or
1759    /// an ellipse, or a path in absolute coordinates.
1760    pub fn blended_in(
1761        &mut self,
1762        mode: crate::BlendMode,
1763        shape: ClipShape,
1764        build: impl FnOnce(&mut Scene),
1765    ) {
1766        let mut inner = Scene::new();
1767        inner.current_clip = self.current_clip;
1768        inner.current_owner = self.current_owner;
1769        inner.current_bounds = self.current_bounds;
1770        build(&mut inner);
1771        self.primitives.push(Primitive::Layer {
1772            primitives: inner.primitives,
1773            opacity: 1.0,
1774            clip: self.current_clip,
1775            clip_shape: shape,
1776            transform: None,
1777            filter: LayerFilter {
1778                blend: Some(mode),
1779                ..LayerFilter::NONE
1780            },
1781            owner: self.current_owner,
1782        });
1783    }
1784
1785    /// Adds **rich** text: resolved runs, mixing styles and colours, laid out as one
1786    /// piece, anchored by its top-left corner.
1787    pub fn rich_text(&mut self, position: Point, runs: Vec<TextRun>) {
1788        self.primitives.push(Primitive::RichText {
1789            position,
1790            runs,
1791            max_width: None,
1792            soft_wrap: true,
1793            align: TextAlign::Start,
1794            clip: self.current_clip,
1795            bounds: self.current_bounds,
1796            owner: self.current_owner,
1797        });
1798    }
1799
1800    /// Adds a **rich paragraph**: the runs wrap beyond `max_width`, and the render's
1801    /// wrapping matches the layout's.
1802    pub fn rich_text_wrapped(&mut self, position: Point, runs: Vec<TextRun>, max_width: f32) {
1803        self.primitives.push(Primitive::RichText {
1804            position,
1805            runs,
1806            max_width: Some(max_width),
1807            soft_wrap: true,
1808            align: TextAlign::Start,
1809            clip: self.current_clip,
1810            bounds: self.current_bounds,
1811            owner: self.current_owner,
1812        });
1813    }
1814
1815    /// Adds a line of text, anchored by its top-left corner — size, weight and italics
1816    /// from the [`ResolvedTextStyle`].
1817    ///
1818    /// It takes a **resolved** style and not a [`TextStyle`](crate::TextStyle) because
1819    /// this is the bottom of the chain: a shaper needs a number and there is nobody left
1820    /// below to ask one of. `color` is settled the same way, by the caller, usually
1821    /// against the theme — which is the one answer this crate cannot work out for itself.
1822    ///
1823    /// There is deliberately **no form of this that takes a bare `f32` size**. A number
1824    /// arriving here has been through [`TextStyle::resolved`](crate::TextStyle::resolved),
1825    /// which is the single place the reader's font size is applied; a second door taking a
1826    /// raw constant is a door onto text that ignores the reader, and forty-seven call sites
1827    /// walked through it (milestone 406).
1828    pub fn text(
1829        &mut self,
1830        position: Point,
1831        text: impl Into<String>,
1832        style: &ResolvedTextStyle,
1833        color: Color,
1834    ) {
1835        self.primitives.push(Primitive::Text {
1836            position,
1837            text: text.into(),
1838            size: style.size,
1839            color,
1840            weight: style.weight,
1841            italic: style.italic,
1842            max_width: None,
1843            soft_wrap: true,
1844            align: TextAlign::Start,
1845            decoration: style.decoration,
1846            decoration_color: style.decoration_color,
1847            height: style.height,
1848            family: style.family,
1849            letter_spacing: style.letter_spacing,
1850            clip: self.current_clip,
1851            bounds: self.current_bounds,
1852            owner: self.current_owner,
1853        });
1854    }
1855
1856    /// Adds a **paragraph**: styled text that wraps beyond `max_width`, the render's
1857    /// wrapping matching the layout's.
1858    pub fn text_wrapped(
1859        &mut self,
1860        position: Point,
1861        text: impl Into<String>,
1862        style: &ResolvedTextStyle,
1863        color: Color,
1864        max_width: f32,
1865    ) {
1866        self.primitives.push(Primitive::Text {
1867            position,
1868            text: text.into(),
1869            size: style.size,
1870            color,
1871            weight: style.weight,
1872            italic: style.italic,
1873            max_width: Some(max_width),
1874            soft_wrap: true,
1875            align: TextAlign::Start,
1876            decoration: style.decoration,
1877            decoration_color: style.decoration_color,
1878            height: style.height,
1879            family: style.family,
1880            letter_spacing: style.letter_spacing,
1881            clip: self.current_clip,
1882            bounds: self.current_bounds,
1883            owner: self.current_owner,
1884        });
1885    }
1886
1887    /// Adds a **block** of styled text: a box of a known width, which the text may be
1888    /// wrapped in and is aligned inside.
1889    ///
1890    /// The three settings travel together because they are one decision. A width with no
1891    /// wrapping is a line that knows where its box ends and runs past it anyway — which is
1892    /// what a clipped or ellipsised line is — and an alignment with no width has nothing
1893    /// to align against.
1894    pub fn text_block(
1895        &mut self,
1896        position: Point,
1897        text: impl Into<String>,
1898        style: &ResolvedTextStyle,
1899        color: Color,
1900        block: TextBlock,
1901    ) {
1902        self.primitives.push(Primitive::Text {
1903            position,
1904            text: text.into(),
1905            size: style.size,
1906            color,
1907            weight: style.weight,
1908            italic: style.italic,
1909            max_width: block.width,
1910            soft_wrap: block.soft_wrap,
1911            align: block.align,
1912            decoration: style.decoration,
1913            decoration_color: style.decoration_color,
1914            height: style.height,
1915            family: style.family,
1916            letter_spacing: style.letter_spacing,
1917            clip: self.current_clip,
1918            bounds: self.current_bounds,
1919            owner: self.current_owner,
1920        });
1921    }
1922
1923    /// Adds a **block** of rich text: resolved runs in a box of a known width, which they
1924    /// may be wrapped in and are aligned inside. The rich counterpart of
1925    /// [`Scene::text_block`].
1926    pub fn rich_text_block(&mut self, position: Point, runs: Vec<TextRun>, block: TextBlock) {
1927        self.primitives.push(Primitive::RichText {
1928            position,
1929            runs,
1930            max_width: block.width,
1931            soft_wrap: block.soft_wrap,
1932            align: block.align,
1933            clip: self.current_clip,
1934            bounds: self.current_bounds,
1935            owner: self.current_owner,
1936        });
1937    }
1938
1939    /// The number of primitives in the scene.
1940    pub fn len(&self) -> usize {
1941        self.primitives.len()
1942    }
1943
1944    /// `true` when the scene holds no primitives at all.
1945    pub fn is_empty(&self) -> bool {
1946        self.primitives.is_empty()
1947    }
1948
1949    /// The primitives, in insertion order, which is drawing order.
1950    pub fn primitives(&self) -> &[Primitive] {
1951        &self.primitives
1952    }
1953
1954    /// A copy of the scene with all geometry scaled by `factor` — the logical →
1955    /// physical conversion for HiDPI rendering.
1956    pub fn scaled(&self, factor: f32) -> Scene {
1957        Scene {
1958            primitives: self.primitives.iter().map(|p| p.scaled(factor)).collect(),
1959            current_clip: self.current_clip.scale(factor),
1960            current_owner: self.current_owner,
1961            current_bounds: self.current_bounds.scale(factor),
1962        }
1963    }
1964}
1965
1966#[cfg(test)]
1967mod tests {
1968    use super::*;
1969
1970    fn close(a: Option<f32>, b: f32) -> bool {
1971        a.is_some_and(|a| (a - b).abs() < 1e-4)
1972    }
1973
1974    fn ramp(shape: RampShape, tile: TileMode) -> GradientRamp {
1975        GradientRamp {
1976            shape,
1977            stops: vec![
1978                (0.0, Color::rgb(1.0, 0.0, 0.0)),
1979                (0.5, Color::rgb(0.0, 1.0, 0.0)),
1980                (1.0, Color::rgb(0.0, 0.0, 1.0)),
1981            ],
1982            tile,
1983            transform: Affine::default(),
1984        }
1985    }
1986
1987    const LINE: RampShape = RampShape::Linear {
1988        from: Point::new(10.0, 0.0),
1989        to: Point::new(30.0, 0.0),
1990    };
1991
1992    /// **Along a line**: the offset is the distance along it, the same across it, and the
1993    /// ends do what the tile mode says.
1994    #[test]
1995    fn a_linear_ramp_runs_along_its_line_and_tiles_past_it() {
1996        let clamp = ramp(LINE, TileMode::Clamp);
1997        assert!(close(clamp.t_at(Point::new(15.0, 0.0)), 0.25));
1998        assert!(
1999            close(clamp.t_at(Point::new(15.0, 99.0)), 0.25),
2000            "constant across"
2001        );
2002        assert!(close(clamp.t_at(Point::new(50.0, 0.0)), 1.0));
2003        let repeat = ramp(LINE, TileMode::Repeated);
2004        assert!(close(repeat.t_at(Point::new(35.0, 0.0)), 0.25));
2005        let mirror = ramp(LINE, TileMode::Mirror);
2006        assert!(close(mirror.t_at(Point::new(35.0, 0.0)), 0.75));
2007        let decal = ramp(LINE, TileMode::Decal);
2008        assert_eq!(
2009            decal.t_at(Point::new(35.0, 0.0)),
2010            None,
2011            "nothing past a decal's end"
2012        );
2013        assert_eq!(decal.color_at(Point::new(35.0, 0.0)), Color::TRANSPARENT);
2014    }
2015
2016    /// **Between the stops**, the mix of the two either side; before and after, the end
2017    /// colours.
2018    #[test]
2019    fn a_ramp_mixes_the_stops_either_side() {
2020        let r = ramp(LINE, TileMode::Clamp);
2021        assert_eq!(r.sample(-1.0), Color::rgb(1.0, 0.0, 0.0));
2022        assert_eq!(r.sample(0.5), Color::rgb(0.0, 1.0, 0.0));
2023        let quarter = r.sample(0.25);
2024        assert!((quarter.r - 0.5).abs() < 1e-6 && (quarter.g - 0.5).abs() < 1e-6);
2025        assert_eq!(r.sample(2.0), Color::rgb(0.0, 0.0, 1.0));
2026    }
2027
2028    /// **Radial**: the distance from the centre in radii, and, with a focal point off the
2029    /// centre, the circle through the point — nearer on the focal side.
2030    #[test]
2031    fn a_radial_ramp_runs_outward_and_bends_towards_its_focal_point() {
2032        let centred = ramp(
2033            RampShape::Radial {
2034                center: Point::new(0.0, 0.0),
2035                radius: 10.0,
2036                focal: Point::new(0.0, 0.0),
2037                focal_radius: 0.0,
2038            },
2039            TileMode::Clamp,
2040        );
2041        assert!(close(centred.t_at(Point::new(5.0, 0.0)), 0.5));
2042        assert!(
2043            close(centred.t_at(Point::new(0.0, -5.0)), 0.5),
2044            "the same all round"
2045        );
2046        let focal = ramp(
2047            RampShape::Radial {
2048                center: Point::new(0.0, 0.0),
2049                radius: 10.0,
2050                focal: Point::new(5.0, 0.0),
2051                focal_radius: 0.0,
2052            },
2053            TileMode::Clamp,
2054        );
2055        // At the focal point, the start; on the outer circle, the end, either side.
2056        assert!(close(focal.t_at(Point::new(5.0, 0.0)), 0.0));
2057        assert!(close(focal.t_at(Point::new(10.0, 0.0)), 1.0));
2058        assert!(close(focal.t_at(Point::new(-10.0, 0.0)), 1.0));
2059        // Halfway from the focal point to each side's edge is halfway through.
2060        assert!(close(focal.t_at(Point::new(7.5, 0.0)), 0.5));
2061        assert!(close(focal.t_at(Point::new(-2.5, 0.0)), 0.5));
2062    }
2063
2064    /// **A transformed gradient is drawn exactly**: the point is mapped back into the
2065    /// gradient's own space, so a turned line runs the turned way.
2066    #[test]
2067    fn a_ramp_follows_its_transform() {
2068        let mut turned = ramp(LINE, TileMode::Clamp);
2069        // A quarter turn about the origin: the line now runs down the y axis.
2070        turned.transform = Affine::rotation(std::f32::consts::FRAC_PI_2);
2071        assert!(close(turned.t_at(Point::new(0.0, 15.0)), 0.25));
2072        assert!(
2073            close(turned.t_at(Point::new(40.0, 15.0)), 0.25),
2074            "constant across it"
2075        );
2076    }
2077
2078    /// **A scaled or moved path takes its gradient with it**: the colour at a point of the
2079    /// moved shape is the colour the same point had before.
2080    #[test]
2081    fn a_ramp_moves_and_scales_with_its_primitive() {
2082        let mut scene = Scene::new();
2083        let shape = Path::rect(Rect::new(10.0, 0.0, 20.0, 10.0));
2084        scene.fill_path_ramp(&shape, ramp(LINE, TileMode::Clamp), 1.0);
2085        let gradient_of = |p: &Primitive| match p {
2086            Primitive::Path {
2087                gradient: Some(PathGradient::Ramp(r)),
2088                ..
2089            } => r.clone(),
2090            _ => panic!("a ramp"),
2091        };
2092        let before = gradient_of(&scene.primitives()[0]);
2093        let scaled = gradient_of(&scene.primitives()[0].scaled_xy(2.0, 3.0));
2094        assert!(close(
2095            scaled.t_at(Point::new(30.0, 6.0)),
2096            before.t_at(Point::new(15.0, 2.0)).unwrap()
2097        ));
2098        let moved = gradient_of(&scene.primitives()[0].translated(5.0, 7.0));
2099        assert!(close(moved.t_at(Point::new(20.0, 7.0)), 0.25));
2100    }
2101    use crate::{Color, Rect};
2102
2103    #[test]
2104    fn fill_rect_pushes_primitive_with_current_clip() {
2105        let mut scene = Scene::new();
2106        assert!(scene.is_empty());
2107        scene.fill_rect(Rect::new(1.0, 2.0, 3.0, 4.0), Color::WHITE);
2108        assert_eq!(scene.len(), 1);
2109        assert_eq!(
2110            scene.primitives()[0],
2111            Primitive::Rect {
2112                rect: Rect::new(1.0, 2.0, 3.0, 4.0),
2113                color: Color::WHITE,
2114                color2: Color::WHITE,
2115                gradient_dir: [0.0, 0.0],
2116                radius: BorderRadius::ZERO,
2117                border_width: 0.0,
2118                border_color: Color::TRANSPARENT,
2119                blur: 0.0,
2120                clip: Rect::UNBOUNDED,
2121                owner: 0,
2122                shader: None,
2123                blur_style: crate::BlurStyle::Normal,
2124            }
2125        );
2126    }
2127
2128    /// A gradient describes geometry, so it has to move with the geometry it
2129    /// describes — otherwise a fade aimed at the top of a list would stay behind when
2130    /// the DPI scale or a layer offset moved the shape.
2131    #[test]
2132    fn a_radial_gradient_travels_with_its_shape() {
2133        let mut scene = Scene::new();
2134        scene.fill_path_radial(
2135            &Path::oval(Rect::new(0.0, 0.0, 40.0, 20.0)),
2136            Color::WHITE,
2137            Color::TRANSPARENT,
2138            Point::new(20.0, 10.0),
2139            Size::new(20.0, 10.0),
2140            0.8,
2141        );
2142        let moved = scene.primitives()[0].clone().scaled_xy(2.0, 3.0);
2143        let Primitive::Path { gradient, .. } = moved.translated(5.0, 7.0) else {
2144            panic!("a filled path");
2145        };
2146        assert_eq!(
2147            gradient,
2148            Some(PathGradient::Radial {
2149                to_color: Color::TRANSPARENT,
2150                center: Point::new(45.0, 37.0),
2151                radii: Size::new(40.0, 30.0),
2152                // The rind is a ratio, so it is the one thing scaling must not touch.
2153                inner: 0.8,
2154            })
2155        );
2156        assert_eq!(gradient.unwrap().faded(0.5).to_color().a, 0.0);
2157    }
2158
2159    #[test]
2160    fn push_faded_scales_alpha_and_keeps_owner() {
2161        let mut scene = Scene::new();
2162        scene.set_owner(42);
2163        scene.fill_rect(
2164            Rect::new(0.0, 0.0, 1.0, 1.0),
2165            Color::rgba(1.0, 0.0, 0.0, 1.0),
2166        );
2167        let source = scene.primitives()[0].clone();
2168        assert_eq!(source.owner(), 42);
2169
2170        let mut target = Scene::new();
2171        target.push_faded(&source, 0.5);
2172        if let Primitive::Rect { color, owner, .. } = target.primitives()[0] {
2173            assert_eq!(color.a, 0.5);
2174            assert_eq!(owner, 42);
2175        } else {
2176            panic!("expected a rect");
2177        }
2178    }
2179
2180    #[test]
2181    fn scaled_multiplies_geometry_not_colors() {
2182        let mut scene = Scene::new();
2183        scene.draw_rect(
2184            Rect::new(2.0, 4.0, 10.0, 20.0),
2185            Color::rgb(1.0, 0.0, 0.0),
2186            3.0,
2187            1.0,
2188            Color::WHITE,
2189        );
2190        scene.text(
2191            Point::new(5.0, 6.0),
2192            "hi",
2193            &ResolvedTextStyle::exact(18.0),
2194            Color::BLACK,
2195        );
2196
2197        let big = scene.scaled(2.0);
2198        match &big.primitives()[0] {
2199            Primitive::Rect {
2200                rect,
2201                radius,
2202                border_width,
2203                color,
2204                ..
2205            } => {
2206                assert_eq!(*rect, Rect::new(4.0, 8.0, 20.0, 40.0));
2207                assert_eq!(*radius, BorderRadius::uniform(6.0));
2208                assert_eq!(*border_width, 2.0);
2209                assert_eq!(*color, Color::rgb(1.0, 0.0, 0.0)); // colour unchanged
2210            }
2211            _ => panic!("expected a rect"),
2212        }
2213        match &big.primitives()[1] {
2214            Primitive::Text {
2215                position,
2216                size,
2217                text,
2218                ..
2219            } => {
2220                assert_eq!(*position, Point::new(10.0, 12.0));
2221                assert_eq!(*size, 36.0);
2222                assert_eq!(text, "hi"); // text unchanged
2223            }
2224            _ => panic!("expected text"),
2225        }
2226    }
2227
2228    #[test]
2229    fn layer_captures_subprimitives_and_opacity() {
2230        let mut scene = Scene::new();
2231        scene.set_owner(7);
2232        scene.set_clip(Rect::new(0.0, 0.0, 50.0, 50.0));
2233        scene.layer(0.5, |inner| {
2234            inner.fill_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Color::WHITE);
2235            inner.fill_rect(Rect::new(5.0, 5.0, 10.0, 10.0), Color::BLACK);
2236        });
2237        assert_eq!(scene.len(), 1);
2238        match &scene.primitives()[0] {
2239            Primitive::Layer {
2240                primitives,
2241                opacity,
2242                clip,
2243                owner,
2244                ..
2245            } => {
2246                assert_eq!(primitives.len(), 2);
2247                assert_eq!(*opacity, 0.5);
2248                assert_eq!(*clip, Rect::new(0.0, 0.0, 50.0, 50.0));
2249                assert_eq!(*owner, 7);
2250            }
2251            _ => panic!("expected a layer"),
2252        }
2253    }
2254
2255    #[test]
2256    fn fading_a_layer_scales_its_group_opacity() {
2257        let mut scene = Scene::new();
2258        scene.layer(0.8, |inner| {
2259            inner.fill_rect(Rect::new(0.0, 0.0, 1.0, 1.0), Color::WHITE)
2260        });
2261        let layer = scene.primitives()[0].clone();
2262        let mut target = Scene::new();
2263        target.push_faded(&layer, 0.5);
2264        match &target.primitives()[0] {
2265            Primitive::Layer { opacity, .. } => assert!((*opacity - 0.4).abs() < 1e-6),
2266            _ => panic!("expected a layer"),
2267        }
2268    }
2269
2270    #[test]
2271    fn scaling_a_layer_scales_its_children() {
2272        let mut scene = Scene::new();
2273        scene.layer(1.0, |inner| {
2274            inner.fill_rect(Rect::new(2.0, 3.0, 4.0, 5.0), Color::WHITE)
2275        });
2276        let big = scene.scaled(2.0);
2277        match &big.primitives()[0] {
2278            Primitive::Layer { primitives, .. } => match &primitives[0] {
2279                Primitive::Rect { rect, .. } => assert_eq!(*rect, Rect::new(4.0, 6.0, 8.0, 10.0)),
2280                _ => panic!("expected a rect"),
2281            },
2282            _ => panic!("expected a layer"),
2283        }
2284    }
2285
2286    #[test]
2287    fn set_clip_applies_to_following_primitives() {
2288        let mut scene = Scene::new();
2289        let clip = Rect::new(0.0, 0.0, 10.0, 10.0);
2290        scene.set_clip(clip);
2291        scene.fill_rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::BLACK);
2292        if let Primitive::Rect { clip: c, .. } = scene.primitives()[0] {
2293            assert_eq!(c, clip);
2294        } else {
2295            panic!("expected a rect");
2296        }
2297    }
2298
2299    /// **A layer moved moves its mask** (milestone 610): a mask's geometry is held in the
2300    /// scene's coordinates once resolved from its box, so a layer drawn somewhere else
2301    /// carries it along. It was left behind, so a moved masked layer was masked where it
2302    /// had been.
2303    #[test]
2304    fn a_moved_layer_carries_its_mask() {
2305        let mask = crate::ShaderMask {
2306            shader: crate::MaskShader::Linear {
2307                from: Point::new(10.0, 0.0),
2308                to: Point::new(110.0, 0.0),
2309                from_color: Color::WHITE,
2310                to_color: Color::TRANSPARENT,
2311            },
2312            blend: crate::BlendMode::Modulate,
2313        };
2314        let layer = Primitive::Layer {
2315            primitives: Vec::new(),
2316            opacity: 1.0,
2317            clip: Rect::new(10.0, 0.0, 100.0, 20.0),
2318            clip_shape: ClipShape::Rect,
2319            transform: None,
2320            filter: crate::LayerFilter {
2321                mask: Some(mask),
2322                ..crate::LayerFilter::NONE
2323            },
2324            owner: 0,
2325        };
2326        let moved = layer.translated(-10.0, 5.0);
2327        let Primitive::Layer { filter, clip, .. } = moved else {
2328            unreachable!()
2329        };
2330        assert_eq!(clip, Rect::new(0.0, 5.0, 100.0, 20.0));
2331        let Some(crate::ShaderMask {
2332            shader: crate::MaskShader::Linear { from, to, .. },
2333            ..
2334        }) = filter.mask
2335        else {
2336            panic!("the mask is kept")
2337        };
2338        assert_eq!((from, to), (Point::new(0.0, 5.0), Point::new(100.0, 5.0)));
2339    }
2340
2341    /// A bitmap only moved and scaled is one image; a turned one is drawn upright at
2342    /// its size on screen and turned by its layer.
2343    #[test]
2344    fn an_image_is_drawn_through_its_map() {
2345        let image = crate::ImageData::from_rgba(4, 2, vec![255; 32]).into_handle();
2346        let mut scene = Scene::new();
2347        scene.set_clip(Rect::new(0.0, 0.0, 500.0, 500.0));
2348        let placed = Affine::scale(10.0, 5.0).then(Affine::translation(30.0, 40.0));
2349        scene.draw_image_mapped(&image, placed, Color::WHITE);
2350        let Primitive::Image { rect, uv, .. } = &scene.primitives()[0] else {
2351            panic!("{:?}", scene.primitives()[0])
2352        };
2353        assert_eq!(*rect, Rect::new(30.0, 40.0, 40.0, 10.0));
2354        assert_eq!(*uv, Rect::new(0.0, 0.0, 1.0, 1.0));
2355
2356        let mut scene = Scene::new();
2357        scene.set_clip(Rect::new(0.0, 0.0, 500.0, 500.0));
2358        let turned = Affine::scale(10.0, 10.0)
2359            .then(Affine::rotation(std::f32::consts::FRAC_PI_2))
2360            .then(Affine::translation(200.0, 100.0));
2361        scene.draw_image_mapped(&image, turned, Color::WHITE);
2362        let Primitive::Layer {
2363            primitives,
2364            transform: Some(transform),
2365            clip,
2366            ..
2367        } = &scene.primitives()[0]
2368        else {
2369            panic!("{:?}", scene.primitives()[0])
2370        };
2371        assert_eq!(*clip, Rect::new(0.0, 0.0, 500.0, 500.0), "the screen's");
2372        let Primitive::Image { rect, clip, .. } = &primitives[0] else {
2373            panic!()
2374        };
2375        assert_eq!(
2376            *rect,
2377            Rect::new(0.0, 0.0, 40.0, 20.0),
2378            "upright, on-screen size"
2379        );
2380        assert_eq!(*clip, *rect);
2381        // The flat image's far corner lands where the map sends the pixels' corner.
2382        let far = transform.affine.apply(Point::new(40.0, 20.0));
2383        let want = turned.apply(Point::new(4.0, 2.0));
2384        assert!(
2385            (far.x - want.x).abs() < 1e-3 && (far.y - want.y).abs() < 1e-3,
2386            "{far:?}"
2387        );
2388
2389        let mut scene = Scene::new();
2390        scene.draw_image_mapped(&image, Affine::scale(0.0, 1.0), Color::WHITE);
2391        assert!(scene.primitives().is_empty(), "flattened to nothing");
2392    }
2393}