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 crate::{
11    Affine, BorderRadius, Color, FontWeight, ImageHandle, LayerFilter, Path, PathVerb, Point, Rect,
12    ResolvedTextStyle, ShaderMask, ShapeBorder, Size, Stroke, TextAlign, TextDecoration, TextRun,
13};
14
15/// The transform applied to a **layer** ([`Primitive::Layer`]) at compositing time:
16/// an arbitrary [`Affine`] (translation, per-axis scale, rotation, or any
17/// composition of them) in screen pixels. The layer is first rendered **flat** into
18/// a texture, as with group opacity, then composited **transformed** — so a single
19/// pass transforms a whole subtree (rects, text, images) without touching any
20/// individual primitive's shaders.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct LayerTransform {
23    /// The local (flat) → screen transform.
24    pub affine: Affine,
25}
26
27impl LayerTransform {
28    /// Wraps any [`Affine`].
29    pub const fn new(affine: Affine) -> Self {
30        Self { affine }
31    }
32
33    /// A rotation of `angle` radians about `pivot`, in screen pixels.
34    pub fn rotation(angle: f32, pivot: Point) -> Self {
35        Self {
36            affine: Affine::rotation(angle).about(pivot),
37        }
38    }
39
40    /// Conjugates the transform by a scale, so it follows `Primitive::scaled`:
41    /// `S ∘ M ∘ S⁻¹`, which is what makes a DPI change scale the whole layer, its
42    /// transform included.
43    pub fn scaled(self, factor: f32) -> Self {
44        self.scaled_xy(factor, factor)
45    }
46
47    /// Like [`LayerTransform::scaled`], but per axis.
48    pub fn scaled_xy(self, sx: f32, sy: f32) -> Self {
49        let conj = Affine::scale(1.0 / sx, 1.0 / sy)
50            .then(self.affine)
51            .then(Affine::scale(sx, sy));
52        Self { affine: conj }
53    }
54
55    /// Conjugates by a translation, so it follows `Primitive::translated`.
56    pub fn translated(self, dx: f32, dy: f32) -> Self {
57        let conj = Affine::translation(-dx, -dy)
58            .then(self.affine)
59            .then(Affine::translation(dx, dy));
60        Self { affine: conj }
61    }
62}
63
64/// How a path's fill **fades**: along a line, or outwards from a centre.
65///
66/// The geometry is in the same space as the path, deliberately, so a fade can be
67/// aimed at something real — the edge a glow springs from, the ellipse whose cap is
68/// the glow — rather than at whatever bounding box the geometry happens to have.
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub enum PathGradient {
71    /// The fill colour at `from`, `to_color` at `to`, clamped outside that span.
72    Linear {
73        /// The colour at `to`. The colour at `from` is the primitive's `fill`.
74        to_color: Color,
75        /// Where the fill colour holds.
76        from: Point,
77        /// Where `to_color` holds.
78        to: Point,
79    },
80    /// The fill colour on the ellipse of radii `inner × radii` about `center`,
81    /// `to_color` on the ellipse of radii `radii`, clamped outside.
82    ///
83    /// A curved edge needs this. A linear fade can only reach zero along a *line*,
84    /// so a shape whose boundary curves away from that line stops while the fade is
85    /// unfinished, and the leftover shows as an edge — which is the very thing a
86    /// glow must not have (milestone 302).
87    Radial {
88        /// The colour on the outer ellipse.
89        to_color: Color,
90        /// The centre of both ellipses.
91        center: Point,
92        /// The outer ellipse's radii, where `to_color` holds.
93        radii: Size,
94        /// Where the fade starts, as a fraction of `radii`. `0` fades from the
95        /// centre outwards; a value near `1` confines it to a thin rind.
96        inner: f32,
97    },
98}
99
100impl PathGradient {
101    /// Scales the geometry per axis, as a DPI or paint scale does.
102    pub fn scaled_xy(self, sx: f32, sy: f32) -> Self {
103        match self {
104            Self::Linear { to_color, from, to } => Self::Linear {
105                to_color,
106                from: Point::new(from.x * sx, from.y * sy),
107                to: Point::new(to.x * sx, to.y * sy),
108            },
109            Self::Radial {
110                to_color,
111                center,
112                radii,
113                inner,
114            } => Self::Radial {
115                to_color,
116                center: Point::new(center.x * sx, center.y * sy),
117                radii: Size::new(radii.width * sx, radii.height * sy),
118                inner,
119            },
120        }
121    }
122
123    /// Moves the geometry with the shape it describes.
124    pub fn translated(self, dx: f32, dy: f32) -> Self {
125        match self {
126            Self::Linear { to_color, from, to } => Self::Linear {
127                to_color,
128                from: Point::new(from.x + dx, from.y + dy),
129                to: Point::new(to.x + dx, to.y + dy),
130            },
131            Self::Radial {
132                to_color,
133                center,
134                radii,
135                inner,
136            } => Self::Radial {
137                to_color,
138                center: Point::new(center.x + dx, center.y + dy),
139                radii,
140                inner,
141            },
142        }
143    }
144
145    /// The colour the fade ends on.
146    pub fn to_color(self) -> Color {
147        match self {
148            Self::Linear { to_color, .. } | Self::Radial { to_color, .. } => to_color,
149        }
150    }
151
152    /// Fades the end colour with the fill it belongs to.
153    pub fn faded(self, opacity: f32) -> Self {
154        match self {
155            Self::Linear { to_color, from, to } => Self::Linear {
156                to_color: to_color.fade(opacity),
157                from,
158                to,
159            },
160            Self::Radial {
161                to_color,
162                center,
163                radii,
164                inner,
165            } => Self::Radial {
166                to_color: to_color.fade(opacity),
167                center,
168                radii,
169                inner,
170            },
171        }
172    }
173}
174
175/// The clip shape of a [`Primitive::Layer`], **inscribed** in its `clip` rectangle.
176/// Compositing multiplies the layer's alpha by the shape's coverage, with
177/// antialiased edges. This is the building block of the `ClipRRect`, `ClipOval`
178/// and `ClipPath` widgets.
179#[derive(Clone, Debug, Default, PartialEq)]
180pub enum ClipShape {
181    /// A crisp rectangular clip — the layer's `clip`, unchanged.
182    #[default]
183    Rect,
184    /// A **rounded-corner** rectangle, with a **per-corner** radius
185    /// ([`BorderRadius`], logical px) clamped to half the `clip`'s smaller side. A
186    /// uniform radius is still `BorderRadius::uniform(r)`.
187    RRect(BorderRadius),
188    /// An **ellipse** inscribed in the `clip` — a circle when the `clip` is square.
189    Oval,
190    /// An **arbitrary path**, in absolute screen coordinates: compositing renders it
191    /// into a coverage **mask** which it multiplies into the layer's alpha. The
192    /// building block of `ClipPath` — stars, notches, free-form shapes.
193    Path(Path),
194}
195
196impl ClipShape {
197    /// Follows a **per-axis** scaling of the layer (DPI, paint scale): only a
198    /// rounded radius changes, by the average of the two factors, since it has no
199    /// axis. The ellipse follows its `clip`, and the rectangle has nothing to scale.
200    pub fn scaled_xy(self, sx: f32, sy: f32) -> ClipShape {
201        match self {
202            ClipShape::RRect(br) => ClipShape::RRect(br.scale((sx + sy) * 0.5)),
203            // The path is in absolute coordinates, so DPI scaling follows it
204            // (uniformly — at DPI, `sx == sy`).
205            ClipShape::Path(p) => ClipShape::Path(p.scaled((sx + sy) * 0.5)),
206            other => other,
207        }
208    }
209}
210
211/// A drawing primitive.
212#[derive(Clone, Debug, PartialEq)]
213pub enum Primitive {
214    /// A rectangle: rounded corners, border, gradient and/or soft shadow.
215    Rect {
216        /// The rectangle's bounds.
217        rect: Rect,
218        /// Fill colour — the start colour when there is a gradient.
219        color: Color,
220        /// The gradient's end colour (`== color` when flat).
221        color2: Color,
222        /// Gradient direction in `[0,1]²` space; `(0,0)` means flat.
223        gradient_dir: [f32; 2],
224        /// Corner radii, per corner.
225        radius: BorderRadius,
226        /// Border width, in logical pixels; `0` draws no border.
227        border_width: f32,
228        /// Border colour.
229        border_color: Color,
230        /// Edge softening, in pixels (0 = crisp; > 0 = a blurred shadow).
231        blur: f32,
232        /// Clip rectangle: nothing is drawn outside it.
233        clip: Rect,
234        /// The emitting widget's identity, used for exit animation.
235        owner: u64,
236    },
237    /// A line of text, anchored by its top-left corner.
238    Text {
239        /// The top-left corner the text is anchored at.
240        position: Point,
241        /// The text itself.
242        text: String,
243        /// The font size, in logical pixels.
244        size: f32,
245        /// The text's colour.
246        color: Color,
247        /// Font weight.
248        weight: FontWeight,
249        /// Italic.
250        italic: bool,
251        /// The width of the box the text was given (`None` = unbounded, in which case
252        /// explicit `\n` characters make the lines). It is what `soft_wrap` wraps at
253        /// and what `align` aligns inside.
254        max_width: Option<f32>,
255        /// Whether `max_width` **wraps** the text or only bounds the alignment. A line
256        /// that is told where its box ends but not to wrap runs past it, which is what
257        /// an ellipsised or clipped single line does.
258        soft_wrap: bool,
259        /// How the lines sit inside `max_width`. Ignored when there is no width to sit
260        /// inside — and deliberately so: handing the renderer a width it did not have
261        /// changes where right-to-left text lands.
262        align: TextAlign,
263        /// Decoration lines (underline, strikethrough, and so on).
264        decoration: TextDecoration,
265        /// Decoration colour; `None` means the text's own colour.
266        decoration_color: Option<Color>,
267        /// The line's height as a **multiple of `size`**; `None` means
268        /// [`crate::DEFAULT_LINE_HEIGHT`]. Carried on the primitive because the renderer
269        /// has to lay the lines out at the height the measurement used, and it cannot
270        /// derive that from the size alone.
271        height: Option<f32>,
272        /// The font family; `None` means the application's default. Carried for the same
273        /// reason as `height`: the renderer must shape with the face the measurement used,
274        /// and it cannot know which that was from the words alone.
275        family: Option<crate::FontFamily>,
276        /// Clip rectangle.
277        clip: Rect,
278        /// The box the text was laid out in — the emitting widget's, so an
279        /// over-estimate of what the glyphs actually cover, never an under-estimate.
280        /// The renderer needs it to know what the text covers; without it text can
281        /// only be painted above everything else in the frame. `Rect::UNBOUNDED` when
282        /// a scene is built by hand rather than by the widget walk, which the renderer
283        /// reads as "unknown, keep it on top".
284        bounds: Rect,
285        /// The emitting widget's identity.
286        owner: u64,
287    },
288    /// **Rich** text: a sequence of resolved runs, mixing styles and colours, laid
289    /// out as one piece, on a single shared baseline.
290    RichText {
291        /// The top-left corner the text is anchored at.
292        position: Point,
293        /// The resolved runs, in reading order, sharing one baseline.
294        runs: Vec<TextRun>,
295        /// The width of the box the runs were given. See [`Primitive::Text`].
296        max_width: Option<f32>,
297        /// Whether `max_width` wraps the runs or only bounds the alignment.
298        soft_wrap: bool,
299        /// How the lines sit inside `max_width`.
300        align: TextAlign,
301        /// Clip rectangle.
302        clip: Rect,
303        /// The box the text was laid out in. See [`Primitive::Text`].
304        bounds: Rect,
305        /// The emitting widget's identity.
306        owner: u64,
307    },
308    /// A **vector path**: arbitrary 2D geometry, filled (`fill`) and/or stroked
309    /// (`stroke`). The building block of icons and custom drawing.
310    Path {
311        /// The geometry to fill and/or stroke.
312        path: Path,
313        /// Interior fill colour (`None` = no fill), and the gradient's start colour
314        /// when there is one.
315        fill: Option<Color>,
316        /// A gradient across the fill, straight or radial; `None` leaves it flat.
317        gradient: Option<PathGradient>,
318        /// Outline (colour plus width); `None` = no outline.
319        stroke: Option<Stroke>,
320        /// Clip rectangle.
321        clip: Rect,
322        /// The emitting widget's identity.
323        owner: u64,
324    },
325    /// A bitmap **image**, sampled into a destination rectangle.
326    Image {
327        /// Shared handle to the pixels (GPU-cached by [`crate::ImageData::id`]).
328        image: ImageHandle,
329        /// Destination rectangle, already fitted per the [`crate::BoxFit`].
330        rect: Rect,
331        /// The sampled sub-region of the texture, in `0..1` (the `Cover` crop).
332        uv: Rect,
333        /// Multiplicative tint (white = unchanged; the alpha drives fading).
334        tint: Color,
335        /// Clip rectangle.
336        clip: Rect,
337        /// The emitting widget's identity.
338        owner: u64,
339    },
340    /// A **layer**: a subgroup of primitives composited **as one** at `opacity`.
341    /// It is rendered separately into a texture at full opacity, then composited,
342    /// which is what makes the group alpha correct: no double blending where the
343    /// inner primitives overlap.
344    Layer {
345        /// The group's primitives, in absolute coordinates like the parent scene.
346        primitives: Vec<Primitive>,
347        /// Group opacity applied to the whole layer (`0..1`).
348        opacity: f32,
349        /// The layer's clip rectangle.
350        clip: Rect,
351        /// The clip **shape** inscribed in `clip` (rounded or elliptical); its
352        /// coverage modulates alpha at compositing time. [`ClipShape::Rect`] is a
353        /// plain rectangular clip, and the default.
354        clip_shape: ClipShape,
355        /// The affine transform (rotation) applied at compositing. `None` means the
356        /// layer is simply composited in place, with group opacity.
357        transform: Option<LayerTransform>,
358        /// The **pixel effects** applied when the layer is composited: an image
359        /// filter, a colour filter, a mask. [`LayerFilter::NONE`] — the usual
360        /// case — leaves compositing on its original path.
361        filter: LayerFilter,
362        /// The emitting widget's identity.
363        owner: u64,
364    },
365}
366
367impl Primitive {
368    /// The identity of the widget that emitted this primitive.
369    pub fn owner(&self) -> u64 {
370        match self {
371            Primitive::Rect { owner, .. } => *owner,
372            Primitive::Text { owner, .. } => *owner,
373            Primitive::RichText { owner, .. } => *owner,
374            Primitive::Path { owner, .. } => *owner,
375            Primitive::Image { owner, .. } => *owner,
376            Primitive::Layer { owner, .. } => *owner,
377        }
378    }
379
380    /// Scales the **geometry** by `factor` — position, size, radius, border, blur,
381    /// clip, font size. Colours and text are unchanged. This is how a logical scene
382    /// becomes a physical one (DPI).
383    pub fn scaled(&self, factor: f32) -> Primitive {
384        self.scaled_xy(factor, factor)
385    }
386
387    /// Scales the geometry **per axis** (`sx` horizontal, `sy` vertical).
388    /// Rectangles and images stretch exactly; **scalar** quantities (corner radius,
389    /// border, blur, path) follow the average of the two factors, having no axis of
390    /// their own; font size follows `sy` and wrap width follows `sx` — approximations
391    /// with no consequence when `sx == sy`, as with a uniform scale or DPI. This is
392    /// the basis of **non-uniform** subtree scaling.
393    pub fn scaled_xy(&self, sx: f32, sy: f32) -> Primitive {
394        let avg = (sx + sy) * 0.5;
395        match self.clone() {
396            Primitive::Rect {
397                rect,
398                color,
399                color2,
400                gradient_dir,
401                radius,
402                border_width,
403                border_color,
404                blur,
405                clip,
406                owner,
407            } => Primitive::Rect {
408                rect: rect.scale_xy(sx, sy),
409                color,
410                color2,
411                gradient_dir,
412                radius: radius.scale(avg),
413                border_width: border_width * avg,
414                border_color,
415                blur: blur * avg,
416                clip: clip.scale_xy(sx, sy),
417                owner,
418            },
419            Primitive::Text {
420                position,
421                text,
422                size,
423                color,
424                weight,
425                italic,
426                max_width,
427                soft_wrap,
428                align,
429                decoration,
430                decoration_color,
431                height,
432                family,
433                clip,
434                bounds,
435                owner,
436            } => Primitive::Text {
437                position: position.scale_xy(sx, sy),
438                text,
439                size: size * sy,
440                color,
441                weight,
442                italic,
443                max_width: max_width.map(|w| w * sx),
444                soft_wrap,
445                align,
446                decoration,
447                decoration_color,
448                height,
449                family,
450                clip: clip.scale_xy(sx, sy),
451                bounds: bounds.scale_xy(sx, sy),
452                owner,
453            },
454            Primitive::RichText {
455                position,
456                mut runs,
457                max_width,
458                soft_wrap,
459                align,
460                clip,
461                bounds,
462                owner,
463            } => {
464                for run in &mut runs {
465                    run.size *= sy;
466                }
467                Primitive::RichText {
468                    position: position.scale_xy(sx, sy),
469                    runs,
470                    max_width: max_width.map(|w| w * sx),
471                    soft_wrap,
472                    align,
473                    clip: clip.scale_xy(sx, sy),
474                    bounds: bounds.scale_xy(sx, sy),
475                    owner,
476                }
477            }
478            Primitive::Path {
479                path,
480                fill,
481                gradient,
482                stroke,
483                clip,
484                owner,
485            } => Primitive::Path {
486                path: path.scaled(avg),
487                fill,
488                gradient: gradient.map(|g| g.scaled_xy(sx, sy)),
489                stroke: stroke.map(|s| Stroke::new(s.color, s.width * avg)),
490                clip: clip.scale_xy(sx, sy),
491                owner,
492            },
493            Primitive::Image {
494                image,
495                rect,
496                uv,
497                tint,
498                clip,
499                owner,
500            } => Primitive::Image {
501                image,
502                rect: rect.scale_xy(sx, sy),
503                // The UV is in 0..1, so it is independent of scale.
504                uv,
505                tint,
506                clip: clip.scale_xy(sx, sy),
507                owner,
508            },
509            Primitive::Layer {
510                primitives,
511                opacity,
512                clip,
513                clip_shape,
514                transform,
515                filter,
516                owner,
517            } => Primitive::Layer {
518                primitives: primitives.iter().map(|p| p.scaled_xy(sx, sy)).collect(),
519                opacity,
520                clip: clip.scale_xy(sx, sy),
521                clip_shape: clip_shape.scaled_xy(sx, sy),
522                transform: transform.map(|t| t.scaled_xy(sx, sy)),
523                filter: filter.scaled_xy(sx, sy),
524                owner,
525            },
526        }
527    }
528
529    /// Offsets the **geometry** by `(dx, dy)` (position, clip); colours, sizes and
530    /// text are unchanged. Combined with [`Primitive::scaled`], it scales a subtree
531    /// **about a pivot**:
532    /// `p.scaled(f).translated(pivot.x * (1 - f), pivot.y * (1 - f))`.
533    pub fn translated(&self, dx: f32, dy: f32) -> Primitive {
534        match self.clone() {
535            Primitive::Rect {
536                rect,
537                color,
538                color2,
539                gradient_dir,
540                radius,
541                border_width,
542                border_color,
543                blur,
544                clip,
545                owner,
546            } => Primitive::Rect {
547                rect: rect.translate(dx, dy),
548                color,
549                color2,
550                gradient_dir,
551                radius,
552                border_width,
553                border_color,
554                blur,
555                clip: clip.translate(dx, dy),
556                owner,
557            },
558            Primitive::Text {
559                position,
560                text,
561                size,
562                color,
563                weight,
564                italic,
565                max_width,
566                soft_wrap,
567                align,
568                decoration,
569                decoration_color,
570                height,
571                family,
572                clip,
573                bounds,
574                owner,
575            } => Primitive::Text {
576                position: Point::new(position.x + dx, position.y + dy),
577                text,
578                size,
579                color,
580                weight,
581                italic,
582                max_width,
583                soft_wrap,
584                align,
585                decoration,
586                decoration_color,
587                height,
588                family,
589                clip: clip.translate(dx, dy),
590                bounds: bounds.translate(dx, dy),
591                owner,
592            },
593            Primitive::RichText {
594                position,
595                runs,
596                max_width,
597                soft_wrap,
598                align,
599                clip,
600                bounds,
601                owner,
602            } => Primitive::RichText {
603                position: Point::new(position.x + dx, position.y + dy),
604                runs,
605                max_width,
606                soft_wrap,
607                align,
608                clip: clip.translate(dx, dy),
609                bounds: bounds.translate(dx, dy),
610                owner,
611            },
612            Primitive::Path {
613                path,
614                fill,
615                gradient,
616                stroke,
617                clip,
618                owner,
619            } => Primitive::Path {
620                path: path.translated(dx, dy),
621                fill,
622                gradient: gradient.map(|g| g.translated(dx, dy)),
623                stroke,
624                clip: clip.translate(dx, dy),
625                owner,
626            },
627            Primitive::Image {
628                image,
629                rect,
630                uv,
631                tint,
632                clip,
633                owner,
634            } => Primitive::Image {
635                image,
636                rect: rect.translate(dx, dy),
637                uv,
638                tint,
639                clip: clip.translate(dx, dy),
640                owner,
641            },
642            Primitive::Layer {
643                primitives,
644                opacity,
645                clip,
646                clip_shape,
647                transform,
648                filter,
649                owner,
650            } => {
651                Primitive::Layer {
652                    primitives: primitives.iter().map(|p| p.translated(dx, dy)).collect(),
653                    opacity,
654                    clip: clip.translate(dx, dy),
655                    // Radius and ellipse are translation-invariant; only the `clip`
656                    // rectangle moves, which is handled above.
657                    clip_shape,
658                    transform: transform.map(|t| t.translated(dx, dy)),
659                    // A blur radius is a length and a mask is written in fractions of
660                    // its box: neither moves with the layer.
661                    filter,
662                    owner,
663                }
664            }
665        }
666    }
667
668    /// This primitive's (approximate) bounding box, in scene coordinates.
669    /// Text is not measured in this module, so a `Text` or `RichText` primitive
670    /// returns a **point** rectangle at its position, enough as an x reference.
671    pub fn bounds(&self) -> Rect {
672        match self {
673            Primitive::Rect { rect, .. } | Primitive::Image { rect, .. } => *rect,
674            Primitive::Text { position, .. } | Primitive::RichText { position, .. } => {
675                Rect::new(position.x, position.y, 0.0, 0.0)
676            }
677            Primitive::Path { path, .. } => {
678                // The control points' bounding box — conservative bounds, since it
679                // encloses the Bézier controls and so is never smaller than the curve.
680                // Accumulated without allocating: min/max updated point by point.
681                let mut bbox: Option<(f32, f32, f32, f32)> = None;
682                let mut include = |p: Point| {
683                    bbox = Some(match bbox {
684                        None => (p.x, p.y, p.x, p.y),
685                        Some((x0, y0, x1, y1)) => {
686                            (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y))
687                        }
688                    });
689                };
690                for v in path.verbs() {
691                    match *v {
692                        PathVerb::MoveTo(p) | PathVerb::LineTo(p) => include(p),
693                        PathVerb::QuadTo { ctrl, to } => {
694                            include(ctrl);
695                            include(to);
696                        }
697                        PathVerb::CubicTo { c1, c2, to } => {
698                            include(c1);
699                            include(c2);
700                            include(to);
701                        }
702                        PathVerb::Close => {}
703                    }
704                }
705                match bbox {
706                    None => Rect::new(0.0, 0.0, 0.0, 0.0),
707                    Some((x0, y0, x1, y1)) => Rect::new(x0, y0, x1 - x0, y1 - y0),
708                }
709            }
710            Primitive::Layer { primitives, .. } => primitives
711                .iter()
712                .map(|p| p.bounds())
713                .reduce(|a, b| a.union(b))
714                .unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)),
715        }
716    }
717
718    /// A copy of this primitive with its **clip** replaced by `clip` — used to
719    /// "un-clip" a captured primitive replayed elsewhere, such as the ghost of a
720    /// dragged header, which overflows its source column.
721    pub fn with_clip(&self, clip: Rect) -> Primitive {
722        match self.clone() {
723            Primitive::Rect {
724                rect,
725                color,
726                color2,
727                gradient_dir,
728                radius,
729                border_width,
730                border_color,
731                blur,
732                owner,
733                ..
734            } => Primitive::Rect {
735                rect,
736                color,
737                color2,
738                gradient_dir,
739                radius,
740                border_width,
741                border_color,
742                blur,
743                clip,
744                owner,
745            },
746            Primitive::Text {
747                position,
748                text,
749                size,
750                color,
751                weight,
752                italic,
753                max_width,
754                soft_wrap,
755                align,
756                decoration,
757                decoration_color,
758                height,
759                family,
760                bounds,
761                owner,
762                ..
763            } => Primitive::Text {
764                position,
765                text,
766                size,
767                color,
768                weight,
769                italic,
770                max_width,
771                soft_wrap,
772                align,
773                decoration,
774                decoration_color,
775                height,
776                family,
777                clip,
778                bounds,
779                owner,
780            },
781            Primitive::RichText {
782                position,
783                runs,
784                max_width,
785                soft_wrap,
786                align,
787                bounds,
788                owner,
789                ..
790            } => Primitive::RichText {
791                position,
792                runs,
793                max_width,
794                soft_wrap,
795                align,
796                clip,
797                bounds,
798                owner,
799            },
800            Primitive::Path {
801                path,
802                fill,
803                gradient,
804                stroke,
805                owner,
806                ..
807            } => Primitive::Path {
808                path,
809                fill,
810                gradient,
811                stroke,
812                clip,
813                owner,
814            },
815            Primitive::Image {
816                image,
817                rect,
818                uv,
819                tint,
820                owner,
821                ..
822            } => Primitive::Image {
823                image,
824                rect,
825                uv,
826                tint,
827                clip,
828                owner,
829            },
830            Primitive::Layer {
831                primitives,
832                opacity,
833                clip_shape,
834                transform,
835                filter,
836                owner,
837                ..
838            } => Primitive::Layer {
839                primitives,
840                opacity,
841                clip,
842                clip_shape,
843                transform,
844                filter,
845                owner,
846            },
847        }
848    }
849
850    /// Scales the geometry by `factor` **about `pivot`**, which stays fixed:
851    /// `pos' = pivot + (pos - pivot) * factor`. Sizes, font, radii and strokes all
852    /// follow the scale.
853    pub fn scaled_about(&self, pivot: Point, factor: f32) -> Primitive {
854        self.scaled_about_xy(pivot, factor, factor)
855    }
856
857    /// Like [`Primitive::scaled_about`], but with **per-axis** factors (`sx`, `sy`)
858    /// — a non-uniform scale about `pivot`.
859    pub fn scaled_about_xy(&self, pivot: Point, sx: f32, sy: f32) -> Primitive {
860        self.scaled_xy(sx, sy)
861            .translated(pivot.x * (1.0 - sx), pivot.y * (1.0 - sy))
862    }
863}
864
865/// The box a block of text is laid out in: how wide it is, whether the text wraps at
866/// that width, and where the lines sit inside it. See [`Scene::text_block`].
867#[derive(Clone, Copy, Debug, PartialEq, Default)]
868pub struct TextBlock {
869    /// The box's width; `None` leaves the text unbounded, and alignment with it.
870    pub width: Option<f32>,
871    /// Whether the text wraps at `width` rather than running past it.
872    pub soft_wrap: bool,
873    /// Where the lines sit inside `width`.
874    pub align: TextAlign,
875}
876
877/// A 2D scene: the declarative description of what to draw.
878#[derive(Clone, Debug)]
879pub struct Scene {
880    primitives: Vec<Primitive>,
881    current_clip: Rect,
882    current_owner: u64,
883    /// The box of the widget currently painting — what text primitives record so the
884    /// renderer knows what they cover. `UNBOUNDED` until a walk sets it.
885    current_bounds: Rect,
886}
887
888impl Default for Scene {
889    fn default() -> Self {
890        Self {
891            primitives: Vec::new(),
892            current_clip: Rect::UNBOUNDED,
893            current_owner: 0,
894            current_bounds: Rect::UNBOUNDED,
895        }
896    }
897}
898
899impl Scene {
900    /// Creates an empty scene, with a neutral clip.
901    pub fn new() -> Self {
902        Self::default()
903    }
904
905    /// Empties the scene so it can be reused on the next frame.
906    pub fn clear(&mut self) {
907        self.primitives.clear();
908        self.current_clip = Rect::UNBOUNDED;
909        self.current_owner = 0;
910        self.current_bounds = Rect::UNBOUNDED;
911    }
912
913    /// Sets the clip rectangle applied to subsequent primitives.
914    pub fn set_clip(&mut self, clip: Rect) {
915        self.current_clip = clip;
916    }
917
918    /// The current clip rectangle, for intersecting with local bounds.
919    pub fn current_clip(&self) -> Rect {
920        self.current_clip
921    }
922
923    /// Sets the emitting widget's identity for subsequent primitives.
924    pub fn set_owner(&mut self, owner: u64) {
925        self.current_owner = owner;
926    }
927
928    /// Declares the box the widget about to paint was laid out in. Text primitives
929    /// record it, which is how the renderer knows what a line of text covers — a
930    /// `Primitive::Text` otherwise says only where it begins. Set by the widget walk
931    /// before every `paint`; a scene built by hand leaves it `UNBOUNDED`, and the
932    /// renderer then keeps that text above everything, as it always did.
933    pub fn set_bounds(&mut self, bounds: Rect) {
934        self.current_bounds = bounds;
935    }
936
937    /// The box currently being painted into.
938    pub fn current_bounds(&self) -> Rect {
939        self.current_bounds
940    }
941
942    /// Appends an **already-formed** primitive, with its clip and owner already
943    /// baked in. This replays a cached subtree (a repaint boundary) as-is, without
944    /// repainting it.
945    pub fn push_primitive(&mut self, primitive: Primitive) {
946        self.primitives.push(primitive);
947    }
948
949    /// Removes and returns the primitives from index `start` onwards, preserving
950    /// order. This **wraps** an already-painted subtree in a layer
951    /// ([`Primitive::Layer`]): paint the subtree, then move its range of primitives
952    /// into a layer, giving it group opacity.
953    pub fn split_off(&mut self, start: usize) -> Vec<Primitive> {
954        self.primitives.split_off(start)
955    }
956
957    /// Replays an existing primitive at reduced opacity — an exit fade.
958    pub fn push_faded(&mut self, primitive: &Primitive, opacity: f32) {
959        let faded = match primitive.clone() {
960            Primitive::Rect {
961                rect,
962                color,
963                color2,
964                gradient_dir,
965                radius,
966                border_width,
967                border_color,
968                blur,
969                clip,
970                owner,
971            } => Primitive::Rect {
972                rect,
973                color: color.fade(opacity),
974                color2: color2.fade(opacity),
975                gradient_dir,
976                radius,
977                border_width,
978                border_color: border_color.fade(opacity),
979                blur,
980                clip,
981                owner,
982            },
983            Primitive::Text {
984                position,
985                text,
986                size,
987                color,
988                weight,
989                italic,
990                max_width,
991                soft_wrap,
992                align,
993                decoration,
994                decoration_color,
995                height,
996                family,
997                clip,
998                bounds,
999                owner,
1000            } => Primitive::Text {
1001                position,
1002                text,
1003                size,
1004                color: color.fade(opacity),
1005                weight,
1006                italic,
1007                max_width,
1008                soft_wrap,
1009                align,
1010                decoration,
1011                decoration_color: decoration_color.map(|c| c.fade(opacity)),
1012                height,
1013                family,
1014                clip,
1015                bounds,
1016                owner,
1017            },
1018            Primitive::RichText {
1019                position,
1020                mut runs,
1021                max_width,
1022                soft_wrap,
1023                align,
1024                clip,
1025                bounds,
1026                owner,
1027            } => {
1028                for run in &mut runs {
1029                    run.color = run.color.fade(opacity);
1030                    run.decoration_color = run.decoration_color.map(|c| c.fade(opacity));
1031                }
1032                Primitive::RichText {
1033                    position,
1034                    runs,
1035                    max_width,
1036                    soft_wrap,
1037                    align,
1038                    clip,
1039                    bounds,
1040                    owner,
1041                }
1042            }
1043            Primitive::Path {
1044                path,
1045                fill,
1046                gradient,
1047                stroke,
1048                clip,
1049                owner,
1050            } => Primitive::Path {
1051                path,
1052                fill: fill.map(|c| c.fade(opacity)),
1053                gradient: gradient.map(|g| g.faded(opacity)),
1054                stroke: stroke.map(|s| Stroke::new(s.color.fade(opacity), s.width)),
1055                clip,
1056                owner,
1057            },
1058            Primitive::Image {
1059                image,
1060                rect,
1061                uv,
1062                tint,
1063                clip,
1064                owner,
1065            } => Primitive::Image {
1066                image,
1067                rect,
1068                uv,
1069                tint: tint.fade(opacity),
1070                clip,
1071                owner,
1072            },
1073            Primitive::Layer {
1074                primitives,
1075                opacity: group,
1076                clip,
1077                clip_shape,
1078                transform,
1079                filter,
1080                owner,
1081            } => Primitive::Layer {
1082                primitives,
1083                // Fading a layer means dimming its group opacity.
1084                opacity: group * opacity,
1085                clip,
1086                clip_shape,
1087                transform,
1088                filter,
1089                owner,
1090            },
1091        };
1092        self.primitives.push(faded);
1093    }
1094
1095    /// Adds a solid rectangle, with square corners and no border.
1096    pub fn fill_rect(&mut self, rect: Rect, color: Color) {
1097        self.primitives.push(Primitive::Rect {
1098            rect,
1099            color,
1100            color2: color,
1101            gradient_dir: [0.0, 0.0],
1102            radius: BorderRadius::ZERO,
1103            border_width: 0.0,
1104            border_color: Color::TRANSPARENT,
1105            blur: 0.0,
1106            clip: self.current_clip,
1107            owner: self.current_owner,
1108        });
1109    }
1110
1111    /// Adds a rectangle with rounded corners (uniform through `f32`, or per corner
1112    /// through [`BorderRadius`]) and/or a border.
1113    /// **Fills a shape**, and draws its edge if it has one.
1114    ///
1115    /// Three of the four shapes are rounded rectangles once the box is known, so they go
1116    /// down the same path as [`Scene::draw_rect`] and cost nothing extra; a bevel becomes
1117    /// a filled path plus a stroked one. A caller who does this by hand at each site gets
1118    /// the fast path wrong somewhere, which is the whole reason this is one function.
1119    pub fn draw_shape(&mut self, rect: Rect, shape: ShapeBorder, fill: Color) {
1120        let side = shape.side();
1121        if let Some((box_, radius)) = shape.as_rounded(rect) {
1122            self.draw_rect(
1123                box_,
1124                fill,
1125                radius,
1126                if side.is_drawn() { side.width } else { 0.0 },
1127                side.color,
1128            );
1129            return;
1130        }
1131        let outline = shape.outline(rect);
1132        self.fill_path(&outline, fill);
1133        if side.is_drawn() {
1134            self.stroke_path(&outline, side.color, side.width);
1135        }
1136    }
1137
1138    /// Adds a flat-coloured rectangle, with an optional border.
1139    pub fn draw_rect(
1140        &mut self,
1141        rect: Rect,
1142        color: Color,
1143        radius: impl Into<BorderRadius>,
1144        border_width: f32,
1145        border_color: Color,
1146    ) {
1147        self.primitives.push(Primitive::Rect {
1148            rect,
1149            color,
1150            color2: color,
1151            gradient_dir: [0.0, 0.0],
1152            radius: radius.into(),
1153            border_width,
1154            border_color,
1155            blur: 0.0,
1156            clip: self.current_clip,
1157            owner: self.current_owner,
1158        });
1159    }
1160
1161    /// Adds a linear-gradient rectangle (`color` → `color2` along `dir`).
1162    // Eight, and every one of them a property of the same rectangle. Grouping them
1163    // into a struct is an API change with call sites, not a lint fix.
1164    #[allow(clippy::too_many_arguments)]
1165    pub fn gradient_rect(
1166        &mut self,
1167        rect: Rect,
1168        color: Color,
1169        color2: Color,
1170        dir: [f32; 2],
1171        radius: impl Into<BorderRadius>,
1172        border_width: f32,
1173        border_color: Color,
1174    ) {
1175        self.primitives.push(Primitive::Rect {
1176            rect,
1177            color,
1178            color2,
1179            gradient_dir: dir,
1180            radius: radius.into(),
1181            border_width,
1182            border_color,
1183            blur: 0.0,
1184            clip: self.current_clip,
1185            owner: self.current_owner,
1186        });
1187    }
1188
1189    /// Adds a soft shadow — a rounded rectangle with a blurred edge, no border.
1190    pub fn shadow(&mut self, rect: Rect, color: Color, radius: impl Into<BorderRadius>, blur: f32) {
1191        self.primitives.push(Primitive::Rect {
1192            rect,
1193            color,
1194            color2: color,
1195            gradient_dir: [0.0, 0.0],
1196            radius: radius.into(),
1197            border_width: 0.0,
1198            border_color: Color::TRANSPARENT,
1199            blur,
1200            clip: self.current_clip,
1201            owner: self.current_owner,
1202        });
1203    }
1204
1205    /// Fills a vector path with a flat colour, using the *non-zero* rule.
1206    pub fn fill_path(&mut self, path: &Path, color: Color) {
1207        self.primitives.push(Primitive::Path {
1208            path: path.clone(),
1209            fill: Some(color),
1210            gradient: None,
1211            stroke: None,
1212            clip: self.current_clip,
1213            owner: self.current_owner,
1214        });
1215    }
1216
1217    /// Fills a path with a **linear gradient**: `from_color` at `from`, `to_color` at
1218    /// `to`, clamped outside that span.
1219    ///
1220    /// The two points are in the path's own space rather than relative to its box, so
1221    /// the fade can be aimed at the thing it is about — the edge a glow springs from,
1222    /// the depth of a band — which is what an ellipse cap needs, its bounding box
1223    /// being mostly off screen.
1224    pub fn fill_path_gradient(
1225        &mut self,
1226        path: &Path,
1227        from_color: Color,
1228        to_color: Color,
1229        from: Point,
1230        to: Point,
1231    ) {
1232        self.primitives.push(Primitive::Path {
1233            path: path.clone(),
1234            fill: Some(from_color),
1235            gradient: Some(PathGradient::Linear { to_color, from, to }),
1236            stroke: None,
1237            clip: self.current_clip,
1238            owner: self.current_owner,
1239        });
1240    }
1241
1242    /// Fills a path with a **radial gradient**: `from_color` on the ellipse of radii
1243    /// `inner × radii` about `center`, `to_color` on the ellipse of radii `radii`.
1244    ///
1245    /// This is what a shape with a **curved** boundary needs in order to fade to
1246    /// nothing all the way round: a straight fade reaches zero on a line, so it still
1247    /// leaves an edge wherever the shape turns away from that line.
1248    pub fn fill_path_radial(
1249        &mut self,
1250        path: &Path,
1251        from_color: Color,
1252        to_color: Color,
1253        center: Point,
1254        radii: Size,
1255        inner: f32,
1256    ) {
1257        self.primitives.push(Primitive::Path {
1258            path: path.clone(),
1259            fill: Some(from_color),
1260            gradient: Some(PathGradient::Radial {
1261                to_color,
1262                center,
1263                radii,
1264                inner,
1265            }),
1266            stroke: None,
1267            clip: self.current_clip,
1268            owner: self.current_owner,
1269        });
1270    }
1271
1272    /// Strokes a path's outline (colour plus width), with no fill.
1273    pub fn stroke_path(&mut self, path: &Path, color: Color, width: f32) {
1274        self.primitives.push(Primitive::Path {
1275            path: path.clone(),
1276            fill: None,
1277            gradient: None,
1278            stroke: Some(Stroke::new(color, width)),
1279            clip: self.current_clip,
1280            owner: self.current_owner,
1281        });
1282    }
1283
1284    /// Adds a path filled **and/or** stroked — both passes in one primitive.
1285    pub fn paint_path(&mut self, path: &Path, fill: Option<Color>, stroke: Option<Stroke>) {
1286        self.primitives.push(Primitive::Path {
1287            path: path.clone(),
1288            fill,
1289            gradient: None,
1290            stroke,
1291            clip: self.current_clip,
1292            owner: self.current_owner,
1293        });
1294    }
1295
1296    /// Draws an image into `rect`, sampling the `uv` sub-region (in `0..1`) and
1297    /// tinting it by `tint` (white = unchanged). Low level: see [`Scene::image`]
1298    /// for automatic fitting through [`crate::BoxFit`].
1299    pub fn draw_image(&mut self, image: &ImageHandle, rect: Rect, uv: Rect, tint: Color) {
1300        self.primitives.push(Primitive::Image {
1301            image: image.clone(),
1302            rect,
1303            uv,
1304            tint,
1305            clip: self.current_clip,
1306            owner: self.current_owner,
1307        });
1308    }
1309
1310    /// Draws an image fitted into `rect` per `fit` (aspect preserved, letterboxed
1311    /// or cropped), untinted.
1312    pub fn image(&mut self, image: &ImageHandle, rect: Rect, fit: crate::BoxFit) {
1313        let (dst, uv) = fit.apply(image.size(), rect);
1314        self.draw_image(image, dst, uv, Color::WHITE);
1315    }
1316
1317    /// Composes a **layer**: `build` fills a subgroup of primitives, which is then
1318    /// composited **as one** at `opacity` (`0..1`). Unlike an opacity applied
1319    /// primitive by primitive, the group alpha stays correct where the inner
1320    /// primitives overlap. The layer inherits the current clip and owner.
1321    pub fn layer(&mut self, opacity: f32, build: impl FnOnce(&mut Scene)) {
1322        let mut inner = Scene::new();
1323        inner.current_clip = self.current_clip;
1324        inner.current_owner = self.current_owner;
1325        build(&mut inner);
1326        self.primitives.push(Primitive::Layer {
1327            primitives: inner.primitives,
1328            opacity,
1329            clip: self.current_clip,
1330            clip_shape: ClipShape::Rect,
1331            transform: None,
1332            filter: LayerFilter::NONE,
1333            owner: self.current_owner,
1334        });
1335    }
1336
1337    /// Composes a **transformed layer**: `build` fills a subgroup of primitives, which is
1338    /// rendered flat and then composited through `transform`.
1339    ///
1340    /// The transform belongs to the group and not to the primitives inside it, which is
1341    /// the whole point: a rotation applies to a rectangle, a line of text and an image at
1342    /// once, and none of them has to know how to rotate itself.
1343    ///
1344    /// **Paint the group where it fits, not where it lands.** The renderer draws the
1345    /// group flat into a *window-sized* texture and composites that through the
1346    /// transform, so anything laid out flat beyond the window is gone before the
1347    /// transform could have brought it back inside. A group that ends up near an edge
1348    /// should be built at the origin and carried there by the transform.
1349    pub fn transformed(&mut self, transform: LayerTransform, build: impl FnOnce(&mut Scene)) {
1350        let mut inner = Scene::new();
1351        inner.current_clip = self.current_clip;
1352        inner.current_owner = self.current_owner;
1353        inner.current_bounds = self.current_bounds;
1354        build(&mut inner);
1355        self.primitives.push(Primitive::Layer {
1356            primitives: inner.primitives,
1357            opacity: 1.0,
1358            clip: self.current_clip,
1359            clip_shape: ClipShape::Rect,
1360            transform: Some(transform),
1361            filter: LayerFilter::NONE,
1362            owner: self.current_owner,
1363        });
1364    }
1365
1366    /// Adds a **masked group**: everything `build` emits is composited as one, with
1367    /// `mask` multiplied into it, so the shader's alpha becomes the group's transparency.
1368    ///
1369    /// A fade has to be a group. Applied primitive by primitive it would fade each of
1370    /// them against the background separately, and where two of them overlap the result
1371    /// is neither one nor the other.
1372    pub fn masked(&mut self, mask: ShaderMask, build: impl FnOnce(&mut Scene)) {
1373        let mut inner = Scene::new();
1374        inner.current_clip = self.current_clip;
1375        inner.current_owner = self.current_owner;
1376        inner.current_bounds = self.current_bounds;
1377        build(&mut inner);
1378        self.primitives.push(Primitive::Layer {
1379            primitives: inner.primitives,
1380            opacity: 1.0,
1381            clip: self.current_clip,
1382            clip_shape: ClipShape::Rect,
1383            transform: None,
1384            filter: LayerFilter {
1385                mask: Some(mask),
1386                ..LayerFilter::NONE
1387            },
1388            owner: self.current_owner,
1389        });
1390    }
1391
1392    /// Adds **rich** text: resolved runs, mixing styles and colours, laid out as one
1393    /// piece, anchored by its top-left corner.
1394    pub fn rich_text(&mut self, position: Point, runs: Vec<TextRun>) {
1395        self.primitives.push(Primitive::RichText {
1396            position,
1397            runs,
1398            max_width: None,
1399            soft_wrap: true,
1400            align: TextAlign::Start,
1401            clip: self.current_clip,
1402            bounds: self.current_bounds,
1403            owner: self.current_owner,
1404        });
1405    }
1406
1407    /// Adds a **rich paragraph**: the runs wrap beyond `max_width`, and the render's
1408    /// wrapping matches the layout's.
1409    pub fn rich_text_wrapped(&mut self, position: Point, runs: Vec<TextRun>, max_width: f32) {
1410        self.primitives.push(Primitive::RichText {
1411            position,
1412            runs,
1413            max_width: Some(max_width),
1414            soft_wrap: true,
1415            align: TextAlign::Start,
1416            clip: self.current_clip,
1417            bounds: self.current_bounds,
1418            owner: self.current_owner,
1419        });
1420    }
1421
1422    /// Adds a line of text, anchored by its top-left corner — size, weight and italics
1423    /// from the [`ResolvedTextStyle`].
1424    ///
1425    /// It takes a **resolved** style and not a [`TextStyle`](crate::TextStyle) because
1426    /// this is the bottom of the chain: a shaper needs a number and there is nobody left
1427    /// below to ask one of. `color` is settled the same way, by the caller, usually
1428    /// against the theme — which is the one answer this crate cannot work out for itself.
1429    ///
1430    /// There is deliberately **no form of this that takes a bare `f32` size**. A number
1431    /// arriving here has been through [`TextStyle::resolved`](crate::TextStyle::resolved),
1432    /// which is the single place the reader's font size is applied; a second door taking a
1433    /// raw constant is a door onto text that ignores the reader, and forty-seven call sites
1434    /// walked through it (milestone 406).
1435    pub fn text(
1436        &mut self,
1437        position: Point,
1438        text: impl Into<String>,
1439        style: &ResolvedTextStyle,
1440        color: Color,
1441    ) {
1442        self.primitives.push(Primitive::Text {
1443            position,
1444            text: text.into(),
1445            size: style.size,
1446            color,
1447            weight: style.weight,
1448            italic: style.italic,
1449            max_width: None,
1450            soft_wrap: true,
1451            align: TextAlign::Start,
1452            decoration: style.decoration,
1453            decoration_color: style.decoration_color,
1454            height: style.height,
1455            family: style.family,
1456            clip: self.current_clip,
1457            bounds: self.current_bounds,
1458            owner: self.current_owner,
1459        });
1460    }
1461
1462    /// Adds a **paragraph**: styled text that wraps beyond `max_width`, the render's
1463    /// wrapping matching the layout's.
1464    pub fn text_wrapped(
1465        &mut self,
1466        position: Point,
1467        text: impl Into<String>,
1468        style: &ResolvedTextStyle,
1469        color: Color,
1470        max_width: f32,
1471    ) {
1472        self.primitives.push(Primitive::Text {
1473            position,
1474            text: text.into(),
1475            size: style.size,
1476            color,
1477            weight: style.weight,
1478            italic: style.italic,
1479            max_width: Some(max_width),
1480            soft_wrap: true,
1481            align: TextAlign::Start,
1482            decoration: style.decoration,
1483            decoration_color: style.decoration_color,
1484            height: style.height,
1485            family: style.family,
1486            clip: self.current_clip,
1487            bounds: self.current_bounds,
1488            owner: self.current_owner,
1489        });
1490    }
1491
1492    /// Adds a **block** of styled text: a box of a known width, which the text may be
1493    /// wrapped in and is aligned inside.
1494    ///
1495    /// The three settings travel together because they are one decision. A width with no
1496    /// wrapping is a line that knows where its box ends and runs past it anyway — which is
1497    /// what a clipped or ellipsised line is — and an alignment with no width has nothing
1498    /// to align against.
1499    pub fn text_block(
1500        &mut self,
1501        position: Point,
1502        text: impl Into<String>,
1503        style: &ResolvedTextStyle,
1504        color: Color,
1505        block: TextBlock,
1506    ) {
1507        self.primitives.push(Primitive::Text {
1508            position,
1509            text: text.into(),
1510            size: style.size,
1511            color,
1512            weight: style.weight,
1513            italic: style.italic,
1514            max_width: block.width,
1515            soft_wrap: block.soft_wrap,
1516            align: block.align,
1517            decoration: style.decoration,
1518            decoration_color: style.decoration_color,
1519            height: style.height,
1520            family: style.family,
1521            clip: self.current_clip,
1522            bounds: self.current_bounds,
1523            owner: self.current_owner,
1524        });
1525    }
1526
1527    /// Adds a **block** of rich text: resolved runs in a box of a known width, which they
1528    /// may be wrapped in and are aligned inside. The rich counterpart of
1529    /// [`Scene::text_block`].
1530    pub fn rich_text_block(&mut self, position: Point, runs: Vec<TextRun>, block: TextBlock) {
1531        self.primitives.push(Primitive::RichText {
1532            position,
1533            runs,
1534            max_width: block.width,
1535            soft_wrap: block.soft_wrap,
1536            align: block.align,
1537            clip: self.current_clip,
1538            bounds: self.current_bounds,
1539            owner: self.current_owner,
1540        });
1541    }
1542
1543    /// The number of primitives in the scene.
1544    pub fn len(&self) -> usize {
1545        self.primitives.len()
1546    }
1547
1548    /// `true` when the scene holds no primitives at all.
1549    pub fn is_empty(&self) -> bool {
1550        self.primitives.is_empty()
1551    }
1552
1553    /// The primitives, in insertion order, which is drawing order.
1554    pub fn primitives(&self) -> &[Primitive] {
1555        &self.primitives
1556    }
1557
1558    /// A copy of the scene with all geometry scaled by `factor` — the logical →
1559    /// physical conversion for HiDPI rendering.
1560    pub fn scaled(&self, factor: f32) -> Scene {
1561        Scene {
1562            primitives: self.primitives.iter().map(|p| p.scaled(factor)).collect(),
1563            current_clip: self.current_clip.scale(factor),
1564            current_owner: self.current_owner,
1565            current_bounds: self.current_bounds.scale(factor),
1566        }
1567    }
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572    use super::*;
1573    use crate::{Color, Rect};
1574
1575    #[test]
1576    fn fill_rect_pushes_primitive_with_current_clip() {
1577        let mut scene = Scene::new();
1578        assert!(scene.is_empty());
1579        scene.fill_rect(Rect::new(1.0, 2.0, 3.0, 4.0), Color::WHITE);
1580        assert_eq!(scene.len(), 1);
1581        assert_eq!(
1582            scene.primitives()[0],
1583            Primitive::Rect {
1584                rect: Rect::new(1.0, 2.0, 3.0, 4.0),
1585                color: Color::WHITE,
1586                color2: Color::WHITE,
1587                gradient_dir: [0.0, 0.0],
1588                radius: BorderRadius::ZERO,
1589                border_width: 0.0,
1590                border_color: Color::TRANSPARENT,
1591                blur: 0.0,
1592                clip: Rect::UNBOUNDED,
1593                owner: 0,
1594            }
1595        );
1596    }
1597
1598    /// A gradient describes geometry, so it has to move with the geometry it
1599    /// describes — otherwise a fade aimed at the top of a list would stay behind when
1600    /// the DPI scale or a layer offset moved the shape.
1601    #[test]
1602    fn a_radial_gradient_travels_with_its_shape() {
1603        let mut scene = Scene::new();
1604        scene.fill_path_radial(
1605            &Path::oval(Rect::new(0.0, 0.0, 40.0, 20.0)),
1606            Color::WHITE,
1607            Color::TRANSPARENT,
1608            Point::new(20.0, 10.0),
1609            Size::new(20.0, 10.0),
1610            0.8,
1611        );
1612        let moved = scene.primitives()[0].clone().scaled_xy(2.0, 3.0);
1613        let Primitive::Path { gradient, .. } = moved.translated(5.0, 7.0) else {
1614            panic!("a filled path");
1615        };
1616        assert_eq!(
1617            gradient,
1618            Some(PathGradient::Radial {
1619                to_color: Color::TRANSPARENT,
1620                center: Point::new(45.0, 37.0),
1621                radii: Size::new(40.0, 30.0),
1622                // The rind is a ratio, so it is the one thing scaling must not touch.
1623                inner: 0.8,
1624            })
1625        );
1626        assert_eq!(gradient.unwrap().faded(0.5).to_color().a, 0.0);
1627    }
1628
1629    #[test]
1630    fn push_faded_scales_alpha_and_keeps_owner() {
1631        let mut scene = Scene::new();
1632        scene.set_owner(42);
1633        scene.fill_rect(
1634            Rect::new(0.0, 0.0, 1.0, 1.0),
1635            Color::rgba(1.0, 0.0, 0.0, 1.0),
1636        );
1637        let source = scene.primitives()[0].clone();
1638        assert_eq!(source.owner(), 42);
1639
1640        let mut target = Scene::new();
1641        target.push_faded(&source, 0.5);
1642        if let Primitive::Rect { color, owner, .. } = target.primitives()[0] {
1643            assert_eq!(color.a, 0.5);
1644            assert_eq!(owner, 42);
1645        } else {
1646            panic!("expected a rect");
1647        }
1648    }
1649
1650    #[test]
1651    fn scaled_multiplies_geometry_not_colors() {
1652        let mut scene = Scene::new();
1653        scene.draw_rect(
1654            Rect::new(2.0, 4.0, 10.0, 20.0),
1655            Color::rgb(1.0, 0.0, 0.0),
1656            3.0,
1657            1.0,
1658            Color::WHITE,
1659        );
1660        scene.text(
1661            Point::new(5.0, 6.0),
1662            "hi",
1663            &ResolvedTextStyle::exact(18.0),
1664            Color::BLACK,
1665        );
1666
1667        let big = scene.scaled(2.0);
1668        match &big.primitives()[0] {
1669            Primitive::Rect {
1670                rect,
1671                radius,
1672                border_width,
1673                color,
1674                ..
1675            } => {
1676                assert_eq!(*rect, Rect::new(4.0, 8.0, 20.0, 40.0));
1677                assert_eq!(*radius, BorderRadius::uniform(6.0));
1678                assert_eq!(*border_width, 2.0);
1679                assert_eq!(*color, Color::rgb(1.0, 0.0, 0.0)); // colour unchanged
1680            }
1681            _ => panic!("expected a rect"),
1682        }
1683        match &big.primitives()[1] {
1684            Primitive::Text {
1685                position,
1686                size,
1687                text,
1688                ..
1689            } => {
1690                assert_eq!(*position, Point::new(10.0, 12.0));
1691                assert_eq!(*size, 36.0);
1692                assert_eq!(text, "hi"); // text unchanged
1693            }
1694            _ => panic!("expected text"),
1695        }
1696    }
1697
1698    #[test]
1699    fn layer_captures_subprimitives_and_opacity() {
1700        let mut scene = Scene::new();
1701        scene.set_owner(7);
1702        scene.set_clip(Rect::new(0.0, 0.0, 50.0, 50.0));
1703        scene.layer(0.5, |inner| {
1704            inner.fill_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Color::WHITE);
1705            inner.fill_rect(Rect::new(5.0, 5.0, 10.0, 10.0), Color::BLACK);
1706        });
1707        assert_eq!(scene.len(), 1);
1708        match &scene.primitives()[0] {
1709            Primitive::Layer {
1710                primitives,
1711                opacity,
1712                clip,
1713                owner,
1714                ..
1715            } => {
1716                assert_eq!(primitives.len(), 2);
1717                assert_eq!(*opacity, 0.5);
1718                assert_eq!(*clip, Rect::new(0.0, 0.0, 50.0, 50.0));
1719                assert_eq!(*owner, 7);
1720            }
1721            _ => panic!("expected a layer"),
1722        }
1723    }
1724
1725    #[test]
1726    fn fading_a_layer_scales_its_group_opacity() {
1727        let mut scene = Scene::new();
1728        scene.layer(0.8, |inner| {
1729            inner.fill_rect(Rect::new(0.0, 0.0, 1.0, 1.0), Color::WHITE)
1730        });
1731        let layer = scene.primitives()[0].clone();
1732        let mut target = Scene::new();
1733        target.push_faded(&layer, 0.5);
1734        match &target.primitives()[0] {
1735            Primitive::Layer { opacity, .. } => assert!((*opacity - 0.4).abs() < 1e-6),
1736            _ => panic!("expected a layer"),
1737        }
1738    }
1739
1740    #[test]
1741    fn scaling_a_layer_scales_its_children() {
1742        let mut scene = Scene::new();
1743        scene.layer(1.0, |inner| {
1744            inner.fill_rect(Rect::new(2.0, 3.0, 4.0, 5.0), Color::WHITE)
1745        });
1746        let big = scene.scaled(2.0);
1747        match &big.primitives()[0] {
1748            Primitive::Layer { primitives, .. } => match &primitives[0] {
1749                Primitive::Rect { rect, .. } => assert_eq!(*rect, Rect::new(4.0, 6.0, 8.0, 10.0)),
1750                _ => panic!("expected a rect"),
1751            },
1752            _ => panic!("expected a layer"),
1753        }
1754    }
1755
1756    #[test]
1757    fn set_clip_applies_to_following_primitives() {
1758        let mut scene = Scene::new();
1759        let clip = Rect::new(0.0, 0.0, 10.0, 10.0);
1760        scene.set_clip(clip);
1761        scene.fill_rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::BLACK);
1762        if let Primitive::Rect { clip: c, .. } = scene.primitives()[0] {
1763            assert_eq!(c, clip);
1764        } else {
1765            panic!("expected a rect");
1766        }
1767    }
1768}