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