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