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}