frus_core/scene.rs
1//! The [`Scene`]: a pure display list, independent of any rendering backend.
2//!
3//! It describes *what to draw* — primitives — while knowing nothing about the
4//! GPU. `frus-gpu` consumes it to produce GPU commands; `frus-widgets` produces
5//! it from a widget tree.
6//!
7//! Every primitive carries a **clip rectangle**, set through
8//! [`Scene::set_clip`] before primitives are added.
9
10use std::sync::Arc;
11
12use crate::{
13 Affine, BorderRadius, Color, FontWeight, ImageHandle, LayerFilter, Path, PathVerb, Point, Rect,
14 ResolvedTextStyle, ShaderMask, ShapeBorder, Size, Stroke, TextAlign, TextDecoration, TextRun,
15 TileMode,
16};
17
18/// The transform applied to a **layer** ([`Primitive::Layer`]) at compositing time:
19/// an arbitrary [`Affine`] (translation, per-axis scale, rotation, or any
20/// composition of them) in screen pixels. The layer is first rendered **flat** into
21/// a texture, as with group opacity, then composited **transformed** — so a single
22/// pass transforms a whole subtree (rects, text, images) without touching any
23/// individual primitive's shaders.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct LayerTransform {
26 /// The local (flat) → screen transform.
27 pub affine: Affine,
28}
29
30impl LayerTransform {
31 /// Wraps any [`Affine`].
32 pub const fn new(affine: Affine) -> Self {
33 Self { affine }
34 }
35
36 /// A rotation of `angle` radians about `pivot`, in screen pixels.
37 pub fn rotation(angle: f32, pivot: Point) -> Self {
38 Self {
39 affine: Affine::rotation(angle).about(pivot),
40 }
41 }
42
43 /// Conjugates the transform by a scale, so it follows `Primitive::scaled`:
44 /// `S ∘ M ∘ S⁻¹`, which is what makes a DPI change scale the whole layer, its
45 /// transform included.
46 pub fn scaled(self, factor: f32) -> Self {
47 self.scaled_xy(factor, factor)
48 }
49
50 /// Like [`LayerTransform::scaled`], but per axis.
51 pub fn scaled_xy(self, sx: f32, sy: f32) -> Self {
52 let conj = Affine::scale(1.0 / sx, 1.0 / sy)
53 .then(self.affine)
54 .then(Affine::scale(sx, sy));
55 Self { affine: conj }
56 }
57
58 /// Conjugates by a translation, so it follows `Primitive::translated`.
59 pub fn translated(self, dx: f32, dy: f32) -> Self {
60 let conj = Affine::translation(-dx, -dy)
61 .then(self.affine)
62 .then(Affine::translation(dx, dy));
63 Self { affine: conj }
64 }
65}
66
67/// **A gradient with any number of stops**, for a path: its shape, its colours, what it
68/// does past its ends, and where it sits.
69///
70/// The shape is in the gradient's own space and `transform` takes it to the path's, so a
71/// gradient that is turned, skewed or squashed — an SVG `gradientTransform`, a bounding
72/// box's units — is drawn exactly: the renderer maps each pixel back into the gradient's
73/// space, where the shape is simple.
74///
75/// ```
76/// use frus_core::{Affine, Color, GradientRamp, Point, RampShape, TileMode};
77/// let ramp = GradientRamp {
78/// shape: RampShape::Linear { from: Point::new(0.0, 0.0), to: Point::new(100.0, 0.0) },
79/// stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)],
80/// tile: TileMode::Clamp,
81/// transform: Affine::default(),
82/// };
83/// assert_eq!(ramp.t_at(Point::new(25.0, 40.0)), Some(0.25));
84/// ```
85#[derive(Clone, Debug, PartialEq)]
86pub struct GradientRamp {
87 /// Where the colours run, in the gradient's own space.
88 pub shape: RampShape,
89 /// `(offset, colour)` pairs, offsets from `0` to `1` in order. A colour holds before
90 /// the first stop and after the last.
91 pub stops: Vec<(f32, Color)>,
92 /// What happens past the ends of the shape.
93 pub tile: TileMode,
94 /// The gradient's space to the path's.
95 pub transform: Affine,
96}
97
98/// Where a [`GradientRamp`]'s colours run.
99#[derive(Clone, Copy, Debug, PartialEq)]
100pub enum RampShape {
101 /// The first stop at `from`, the last at `to`, constant across the line.
102 Linear {
103 /// Where offset `0` is.
104 from: Point,
105 /// Where offset `1` is.
106 to: Point,
107 },
108 /// Offset `0` on the circle about `focal` of radius `focal_radius`, offset `1` on the
109 /// circle about `center` of radius `radius`, and the circles between them for the
110 /// offsets between. With the focal point at the centre and no focal radius, it is the
111 /// plain outward fade.
112 Radial {
113 /// The centre of the outer circle.
114 center: Point,
115 /// The radius of the outer circle.
116 radius: f32,
117 /// The centre of the inner circle.
118 focal: Point,
119 /// The radius of the inner circle.
120 focal_radius: f32,
121 },
122}
123
124impl GradientRamp {
125 /// How far through the gradient the point `p` (in the path's space) is, tiled; `None`
126 /// where nothing is drawn — past the ends of a [`TileMode::Decal`] one, or outside
127 /// both circles' reach. What the renderer computes for every pixel.
128 pub fn t_at(&self, p: Point) -> Option<f32> {
129 let p = self.transform.inverse().apply(p);
130 let t = match self.shape {
131 RampShape::Linear { from, to } => {
132 let (dx, dy) = (to.x - from.x, to.y - from.y);
133 ((p.x - from.x) * dx + (p.y - from.y) * dy) / (dx * dx + dy * dy).max(1e-6)
134 }
135 RampShape::Radial {
136 center,
137 radius,
138 focal,
139 focal_radius,
140 } => {
141 // The largest t whose circle — centre and radius moving from the focal
142 // circle's to the outer one's — passes through p.
143 let (cdx, cdy) = (center.x - focal.x, center.y - focal.y);
144 let dr = radius - focal_radius;
145 let (pdx, pdy) = (p.x - focal.x, p.y - focal.y);
146 let a = cdx * cdx + cdy * cdy - dr * dr;
147 let b = pdx * cdx + pdy * cdy + focal_radius * dr;
148 let c = pdx * pdx + pdy * pdy - focal_radius * focal_radius;
149 if a.abs() < 1e-4 {
150 c / (2.0 * b).max(1e-6)
151 } else {
152 let disc = b * b - a * c;
153 if disc < 0.0 {
154 return None;
155 }
156 let root = disc.sqrt();
157 let t = (b + root) / a;
158 if focal_radius + t * dr < 0.0 {
159 (b - root) / a
160 } else {
161 t
162 }
163 }
164 }
165 };
166 crate::tile(t, self.tile)
167 }
168
169 /// The colour at offset `t`, between the stops either side of it.
170 pub fn sample(&self, t: f32) -> Color {
171 match self.stops.as_slice() {
172 [] => Color::TRANSPARENT,
173 [(_, only)] => *only,
174 stops => {
175 if t <= stops[0].0 {
176 return stops[0].1;
177 }
178 for pair in stops.windows(2) {
179 let ((a, from), (b, to)) = (pair[0], pair[1]);
180 if t <= b {
181 let k = if b > a { (t - a) / (b - a) } else { 1.0 };
182 return from.lerp(to, k);
183 }
184 }
185 stops[stops.len() - 1].1
186 }
187 }
188 }
189
190 /// The colour at the point `p`, in the path's space: transparent where nothing is
191 /// drawn.
192 pub fn color_at(&self, p: Point) -> Color {
193 self.t_at(p).map_or(Color::TRANSPARENT, |t| self.sample(t))
194 }
195
196 /// The same gradient after its path is moved by `then`.
197 fn followed_by(&self, then: Affine) -> GradientRamp {
198 GradientRamp {
199 transform: self.transform.then(then),
200 ..self.clone()
201 }
202 }
203}
204
205/// How a path's fill **fades**: along a line, or outwards from a centre.
206///
207/// The geometry is in the same space as the path, deliberately, so a fade can be
208/// aimed at something real — the edge a glow springs from, the ellipse whose cap is
209/// the glow — rather than at whatever bounding box the geometry happens to have.
210#[derive(Clone, Debug, PartialEq)]
211pub enum PathGradient {
212 /// The fill colour at `from`, `to_color` at `to`, clamped outside that span.
213 Linear {
214 /// The colour at `to`. The colour at `from` is the primitive's `fill`.
215 to_color: Color,
216 /// Where the fill colour holds.
217 from: Point,
218 /// Where `to_color` holds.
219 to: Point,
220 },
221 /// The fill colour on the ellipse of radii `inner × radii` about `center`,
222 /// `to_color` on the ellipse of radii `radii`, clamped outside.
223 ///
224 /// A curved edge needs this. A linear fade can only reach zero along a *line*,
225 /// so a shape whose boundary curves away from that line stops while the fade is
226 /// unfinished, and the leftover shows as an edge — which is the very thing a
227 /// glow must not have (milestone 302).
228 Radial {
229 /// The colour on the outer ellipse.
230 to_color: Color,
231 /// The centre of both ellipses.
232 center: Point,
233 /// The outer ellipse's radii, where `to_color` holds.
234 radii: Size,
235 /// Where the fade starts, as a fraction of `radii`. `0` fades from the
236 /// centre outwards; a value near `1` confines it to a thin rind.
237 inner: f32,
238 },
239 /// **Any number of colour stops**, along a line or between two circles, in a space of
240 /// its own — what vector artwork's gradients are. The primitive's fill colour
241 /// multiplies it: white leaves it as it is, and a faded white fades it.
242 Ramp(Arc<GradientRamp>),
243}
244
245impl PathGradient {
246 /// Scales the geometry per axis, as a DPI or paint scale does.
247 pub fn scaled_xy(self, sx: f32, sy: f32) -> Self {
248 match self {
249 Self::Linear { to_color, from, to } => Self::Linear {
250 to_color,
251 from: Point::new(from.x * sx, from.y * sy),
252 to: Point::new(to.x * sx, to.y * sy),
253 },
254 Self::Radial {
255 to_color,
256 center,
257 radii,
258 inner,
259 } => Self::Radial {
260 to_color,
261 center: Point::new(center.x * sx, center.y * sy),
262 radii: Size::new(radii.width * sx, radii.height * sy),
263 inner,
264 },
265 Self::Ramp(ramp) => Self::Ramp(Arc::new(ramp.followed_by(Affine::scale(sx, sy)))),
266 }
267 }
268
269 /// Moves the geometry with the shape it describes.
270 pub fn translated(self, dx: f32, dy: f32) -> Self {
271 match self {
272 Self::Linear { to_color, from, to } => Self::Linear {
273 to_color,
274 from: Point::new(from.x + dx, from.y + dy),
275 to: Point::new(to.x + dx, to.y + dy),
276 },
277 Self::Radial {
278 to_color,
279 center,
280 radii,
281 inner,
282 } => Self::Radial {
283 to_color,
284 center: Point::new(center.x + dx, center.y + dy),
285 radii,
286 inner,
287 },
288 Self::Ramp(ramp) => Self::Ramp(Arc::new(ramp.followed_by(Affine::translation(dx, dy)))),
289 }
290 }
291
292 /// The colour the fade ends on.
293 pub fn to_color(self) -> Color {
294 match self {
295 Self::Linear { to_color, .. } | Self::Radial { to_color, .. } => to_color,
296 Self::Ramp(ramp) => ramp.stops.last().map_or(Color::TRANSPARENT, |(_, c)| *c),
297 }
298 }
299
300 /// Fades the end colour with the fill it belongs to.
301 pub fn faded(self, opacity: f32) -> Self {
302 match self {
303 Self::Linear { to_color, from, to } => Self::Linear {
304 to_color: to_color.fade(opacity),
305 from,
306 to,
307 },
308 Self::Radial {
309 to_color,
310 center,
311 radii,
312 inner,
313 } => Self::Radial {
314 to_color: to_color.fade(opacity),
315 center,
316 radii,
317 inner,
318 },
319 // The fill colour multiplies a ramp, and the fill is faded with it.
320 Self::Ramp(ramp) => Self::Ramp(ramp),
321 }
322 }
323}
324
325/// **Which side of a shape a blur keeps** (milestone 645), the reference's `BlurStyle`.
326///
327/// A blurred shape fades across its edge, half in and half out. The other three styles
328/// keep one side of that fade and fill or empty the other: a solid shape with a glow, a
329/// glow with a hole where the shape was, a shape whose edge darkens inward.
330#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
331pub enum BlurStyle {
332 /// Fuzzy inside and outside: the shape blurred, what a shadow is.
333 #[default]
334 Normal,
335 /// Solid inside, fuzzy outside.
336 Solid,
337 /// Nothing inside, fuzzy outside.
338 Outer,
339 /// Fuzzy inside, nothing outside.
340 Inner,
341}
342
343impl BlurStyle {
344 /// The number the renderer reads.
345 pub const fn code(self) -> u32 {
346 match self {
347 BlurStyle::Normal => 0,
348 BlurStyle::Solid => 1,
349 BlurStyle::Outer => 2,
350 BlurStyle::Inner => 3,
351 }
352 }
353}
354
355/// The clip shape of a [`Primitive::Layer`], **inscribed** in its `clip` rectangle.
356/// Compositing multiplies the layer's alpha by the shape's coverage, with
357/// antialiased edges. This is the building block of the `ClipRRect`, `ClipOval`
358/// and `ClipPath` widgets.
359#[derive(Clone, Debug, Default, PartialEq)]
360pub enum ClipShape {
361 /// A crisp rectangular clip — the layer's `clip`, unchanged.
362 #[default]
363 Rect,
364 /// A **rounded-corner** rectangle, with a **per-corner** radius
365 /// ([`BorderRadius`], logical px) clamped to half the `clip`'s smaller side. A
366 /// uniform radius is still `BorderRadius::uniform(r)`.
367 RRect(BorderRadius),
368 /// An **ellipse** inscribed in the `clip` — a circle when the `clip` is square.
369 Oval,
370 /// An **arbitrary path**, in absolute screen coordinates: compositing renders it
371 /// into a coverage **mask** which it multiplies into the layer's alpha. The
372 /// building block of `ClipPath` — stars, notches, free-form shapes.
373 Path(Path),
374}
375
376impl ClipShape {
377 /// Follows a **per-axis** scaling of the layer (DPI, paint scale): only a
378 /// rounded radius changes, by the average of the two factors, since it has no
379 /// axis. The ellipse follows its `clip`, and the rectangle has nothing to scale.
380 pub fn scaled_xy(self, sx: f32, sy: f32) -> ClipShape {
381 match self {
382 ClipShape::RRect(br) => ClipShape::RRect(br.scale((sx + sy) * 0.5)),
383 // The path is in absolute coordinates, so DPI scaling follows it
384 // (uniformly — at DPI, `sx == sy`).
385 ClipShape::Path(p) => ClipShape::Path(p.scaled((sx + sy) * 0.5)),
386 other => other,
387 }
388 }
389}
390
391/// A drawing primitive.
392#[derive(Clone, Debug, PartialEq)]
393pub enum Primitive {
394 /// A rectangle: rounded corners, border, gradient and/or soft shadow.
395 Rect {
396 /// The rectangle's bounds.
397 rect: Rect,
398 /// Fill colour — the start colour when there is a gradient.
399 color: Color,
400 /// The gradient's end colour (`== color` when flat).
401 color2: Color,
402 /// Gradient direction in `[0,1]²` space; `(0,0)` means flat.
403 gradient_dir: [f32; 2],
404 /// Corner radii, per corner.
405 radius: BorderRadius,
406 /// Border width, in logical pixels; `0` draws no border.
407 border_width: f32,
408 /// Border colour.
409 border_color: Color,
410 /// Edge softening, in pixels (0 = crisp; > 0 = a blurred shadow).
411 blur: f32,
412 /// Clip rectangle: nothing is drawn outside it.
413 clip: Rect,
414 /// The emitting widget's identity, used for exit animation.
415 owner: u64,
416 /// **A gradient filling the shape** (milestone 644), as the reference describes it:
417 /// relative to `rect`, so it scales with it. When set it replaces `color`,
418 /// `color2` and `gradient_dir`, which hold its first and last colours for
419 /// whatever reads a rectangle's colour.
420 shader: Option<std::sync::Arc<crate::Gradient>>,
421 /// Where a blurred edge keeps its strength: either side of the shape, inside
422 /// only, outside only (milestone 645). Read only when `blur` is above nought.
423 blur_style: BlurStyle,
424 },
425 /// A line of text, anchored by its top-left corner.
426 Text {
427 /// The top-left corner the text is anchored at.
428 position: Point,
429 /// The text itself.
430 text: String,
431 /// The font size, in logical pixels.
432 size: f32,
433 /// The text's colour.
434 color: Color,
435 /// Font weight.
436 weight: FontWeight,
437 /// Italic.
438 italic: bool,
439 /// The width of the box the text was given (`None` = unbounded, in which case
440 /// explicit `\n` characters make the lines). It is what `soft_wrap` wraps at
441 /// and what `align` aligns inside.
442 max_width: Option<f32>,
443 /// Whether `max_width` **wraps** the text or only bounds the alignment. A line
444 /// that is told where its box ends but not to wrap runs past it, which is what
445 /// an ellipsised or clipped single line does.
446 soft_wrap: bool,
447 /// How the lines sit inside `max_width`. Ignored when there is no width to sit
448 /// inside — and deliberately so: handing the renderer a width it did not have
449 /// changes where right-to-left text lands.
450 align: TextAlign,
451 /// Decoration lines (underline, strikethrough, and so on).
452 decoration: TextDecoration,
453 /// Decoration colour; `None` means the text's own colour.
454 decoration_color: Option<Color>,
455 /// The line's height as a **multiple of `size`**; `None` means
456 /// [`crate::DEFAULT_LINE_HEIGHT`]. Carried on the primitive because the renderer
457 /// has to lay the lines out at the height the measurement used, and it cannot
458 /// derive that from the size alone.
459 height: Option<f32>,
460 /// The font family; `None` means the application's default. Carried for the same
461 /// reason as `height`: the renderer must shape with the face the measurement used,
462 /// and it cannot know which that was from the words alone.
463 family: Option<crate::FontFamily>,
464 /// The room added after every character, in logical pixels — carried for the same
465 /// reason again: the renderer must space the glyphs as the measurement did
466 /// (milestone 623).
467 letter_spacing: f32,
468 /// Clip rectangle.
469 clip: Rect,
470 /// The box the text was laid out in — the emitting widget's, so an
471 /// over-estimate of what the glyphs actually cover, never an under-estimate.
472 /// The renderer needs it to know what the text covers; without it text can
473 /// only be painted above everything else in the frame. `Rect::UNBOUNDED` when
474 /// a scene is built by hand rather than by the widget walk, which the renderer
475 /// reads as "unknown, keep it on top".
476 bounds: Rect,
477 /// The emitting widget's identity.
478 owner: u64,
479 },
480 /// **Rich** text: a sequence of resolved runs, mixing styles and colours, laid
481 /// out as one piece, on a single shared baseline.
482 RichText {
483 /// The top-left corner the text is anchored at.
484 position: Point,
485 /// The resolved runs, in reading order, sharing one baseline.
486 runs: Vec<TextRun>,
487 /// The width of the box the runs were given. See [`Primitive::Text`].
488 max_width: Option<f32>,
489 /// Whether `max_width` wraps the runs or only bounds the alignment.
490 soft_wrap: bool,
491 /// How the lines sit inside `max_width`.
492 align: TextAlign,
493 /// Clip rectangle.
494 clip: Rect,
495 /// The box the text was laid out in. See [`Primitive::Text`].
496 bounds: Rect,
497 /// The emitting widget's identity.
498 owner: u64,
499 },
500 /// A **vector path**: arbitrary 2D geometry, filled (`fill`) and/or stroked
501 /// (`stroke`). The building block of icons and custom drawing.
502 Path {
503 /// The geometry to fill and/or stroke.
504 path: Path,
505 /// Interior fill colour (`None` = no fill), and the gradient's start colour
506 /// when there is one.
507 fill: Option<Color>,
508 /// A gradient across the fill, straight or radial; `None` leaves it flat.
509 gradient: Option<PathGradient>,
510 /// Outline (colour plus width); `None` = no outline.
511 stroke: Option<Stroke>,
512 /// Clip rectangle.
513 clip: Rect,
514 /// The emitting widget's identity.
515 owner: u64,
516 },
517 /// A bitmap **image**, sampled into a destination rectangle.
518 Image {
519 /// Shared handle to the pixels (GPU-cached by [`crate::ImageData::id`]).
520 image: ImageHandle,
521 /// Destination rectangle, already fitted per the [`crate::BoxFit`].
522 rect: Rect,
523 /// The sampled sub-region of the texture, in `0..1` (the `Cover` crop).
524 uv: Rect,
525 /// Multiplicative tint (white = unchanged; the alpha drives fading).
526 tint: Color,
527 /// Whether sampling stays **strictly** inside `uv` (milestone 645): a filtered
528 /// sample never reaches the texels around it. The parts of a sliced picture
529 /// need it, or each would bleed its neighbours along its edges; a picture drawn
530 /// whole or cropped does without, as the reference's does.
531 strict: bool,
532 /// Clip rectangle.
533 clip: Rect,
534 /// The emitting widget's identity.
535 owner: u64,
536 },
537 /// A **layer**: a subgroup of primitives composited **as one** at `opacity`.
538 /// It is rendered separately into a texture at full opacity, then composited,
539 /// which is what makes the group alpha correct: no double blending where the
540 /// inner primitives overlap.
541 Layer {
542 /// The group's primitives, in absolute coordinates like the parent scene.
543 primitives: Vec<Primitive>,
544 /// Group opacity applied to the whole layer (`0..1`).
545 opacity: f32,
546 /// The layer's clip rectangle.
547 clip: Rect,
548 /// The clip **shape** inscribed in `clip` (rounded or elliptical); its
549 /// coverage modulates alpha at compositing time. [`ClipShape::Rect`] is a
550 /// plain rectangular clip, and the default.
551 clip_shape: ClipShape,
552 /// The affine transform (rotation) applied at compositing. `None` means the
553 /// layer is simply composited in place, with group opacity.
554 transform: Option<LayerTransform>,
555 /// The **pixel effects** applied when the layer is composited: an image
556 /// filter, a colour filter, a mask. [`LayerFilter::NONE`] — the usual
557 /// case — leaves compositing on its original path.
558 filter: LayerFilter,
559 /// The emitting widget's identity.
560 owner: u64,
561 },
562}
563
564impl Primitive {
565 /// The identity of the widget that emitted this primitive.
566 pub fn owner(&self) -> u64 {
567 match self {
568 Primitive::Rect { owner, .. } => *owner,
569 Primitive::Text { owner, .. } => *owner,
570 Primitive::RichText { owner, .. } => *owner,
571 Primitive::Path { owner, .. } => *owner,
572 Primitive::Image { owner, .. } => *owner,
573 Primitive::Layer { owner, .. } => *owner,
574 }
575 }
576
577 /// Scales the **geometry** by `factor` — position, size, radius, border, blur,
578 /// clip, font size. Colours and text are unchanged. This is how a logical scene
579 /// becomes a physical one (DPI).
580 pub fn scaled(&self, factor: f32) -> Primitive {
581 self.scaled_xy(factor, factor)
582 }
583
584 /// Scales the geometry **per axis** (`sx` horizontal, `sy` vertical).
585 /// Rectangles and images stretch exactly; **scalar** quantities (corner radius,
586 /// border, blur, path) follow the average of the two factors, having no axis of
587 /// their own; font size follows `sy` and wrap width follows `sx` — approximations
588 /// with no consequence when `sx == sy`, as with a uniform scale or DPI. This is
589 /// the basis of **non-uniform** subtree scaling.
590 pub fn scaled_xy(&self, sx: f32, sy: f32) -> Primitive {
591 let avg = (sx + sy) * 0.5;
592 match self.clone() {
593 Primitive::Rect {
594 rect,
595 color,
596 color2,
597 gradient_dir,
598 radius,
599 border_width,
600 border_color,
601 blur,
602 clip,
603 owner,
604 shader,
605 blur_style,
606 } => Primitive::Rect {
607 rect: rect.scale_xy(sx, sy),
608 color,
609 color2,
610 gradient_dir,
611 radius: radius.scale(avg),
612 border_width: border_width * avg,
613 border_color,
614 blur: blur * avg,
615 clip: clip.scale_xy(sx, sy),
616 owner,
617 shader,
618 blur_style,
619 },
620 Primitive::Text {
621 position,
622 text,
623 size,
624 color,
625 weight,
626 italic,
627 max_width,
628 soft_wrap,
629 align,
630 decoration,
631 decoration_color,
632 height,
633 family,
634 letter_spacing,
635 clip,
636 bounds,
637 owner,
638 } => Primitive::Text {
639 position: position.scale_xy(sx, sy),
640 text,
641 size: size * sy,
642 color,
643 weight,
644 italic,
645 max_width: max_width.map(|w| w * sx),
646 soft_wrap,
647 align,
648 decoration,
649 decoration_color,
650 height,
651 family,
652 letter_spacing: letter_spacing * sx,
653 clip: clip.scale_xy(sx, sy),
654 bounds: bounds.scale_xy(sx, sy),
655 owner,
656 },
657 Primitive::RichText {
658 position,
659 mut runs,
660 max_width,
661 soft_wrap,
662 align,
663 clip,
664 bounds,
665 owner,
666 } => {
667 for run in &mut runs {
668 run.size *= sy;
669 }
670 Primitive::RichText {
671 position: position.scale_xy(sx, sy),
672 runs,
673 max_width: max_width.map(|w| w * sx),
674 soft_wrap,
675 align,
676 clip: clip.scale_xy(sx, sy),
677 bounds: bounds.scale_xy(sx, sy),
678 owner,
679 }
680 }
681 Primitive::Path {
682 path,
683 fill,
684 gradient,
685 stroke,
686 clip,
687 owner,
688 } => Primitive::Path {
689 path: path.scaled(avg),
690 fill,
691 gradient: gradient.map(|g| g.scaled_xy(sx, sy)),
692 stroke: stroke.map(|s| Stroke::new(s.color, s.width * avg)),
693 clip: clip.scale_xy(sx, sy),
694 owner,
695 },
696 Primitive::Image {
697 image,
698 rect,
699 uv,
700 tint,
701 strict,
702 clip,
703 owner,
704 } => Primitive::Image {
705 image,
706 rect: rect.scale_xy(sx, sy),
707 // The UV is in 0..1, so it is independent of scale.
708 uv,
709 tint,
710 strict,
711 clip: clip.scale_xy(sx, sy),
712 owner,
713 },
714 Primitive::Layer {
715 primitives,
716 opacity,
717 clip,
718 clip_shape,
719 transform,
720 filter,
721 owner,
722 } => Primitive::Layer {
723 primitives: primitives.iter().map(|p| p.scaled_xy(sx, sy)).collect(),
724 opacity,
725 clip: clip.scale_xy(sx, sy),
726 clip_shape: clip_shape.scaled_xy(sx, sy),
727 transform: transform.map(|t| t.scaled_xy(sx, sy)),
728 filter: filter.scaled_xy(sx, sy),
729 owner,
730 },
731 }
732 }
733
734 /// Offsets the **geometry** by `(dx, dy)` (position, clip); colours, sizes and
735 /// text are unchanged. Combined with [`Primitive::scaled`], it scales a subtree
736 /// **about a pivot**:
737 /// `p.scaled(f).translated(pivot.x * (1 - f), pivot.y * (1 - f))`.
738 pub fn translated(&self, dx: f32, dy: f32) -> Primitive {
739 match self.clone() {
740 Primitive::Rect {
741 rect,
742 color,
743 color2,
744 gradient_dir,
745 radius,
746 border_width,
747 border_color,
748 blur,
749 clip,
750 owner,
751 shader,
752 blur_style,
753 } => Primitive::Rect {
754 rect: rect.translate(dx, dy),
755 color,
756 color2,
757 gradient_dir,
758 radius,
759 border_width,
760 border_color,
761 blur,
762 clip: clip.translate(dx, dy),
763 owner,
764 shader,
765 blur_style,
766 },
767 Primitive::Text {
768 position,
769 text,
770 size,
771 color,
772 weight,
773 italic,
774 max_width,
775 soft_wrap,
776 align,
777 decoration,
778 decoration_color,
779 height,
780 family,
781 letter_spacing,
782 clip,
783 bounds,
784 owner,
785 } => Primitive::Text {
786 position: Point::new(position.x + dx, position.y + dy),
787 text,
788 size,
789 color,
790 weight,
791 italic,
792 max_width,
793 soft_wrap,
794 align,
795 decoration,
796 decoration_color,
797 height,
798 family,
799 letter_spacing,
800 clip: clip.translate(dx, dy),
801 bounds: bounds.translate(dx, dy),
802 owner,
803 },
804 Primitive::RichText {
805 position,
806 runs,
807 max_width,
808 soft_wrap,
809 align,
810 clip,
811 bounds,
812 owner,
813 } => Primitive::RichText {
814 position: Point::new(position.x + dx, position.y + dy),
815 runs,
816 max_width,
817 soft_wrap,
818 align,
819 clip: clip.translate(dx, dy),
820 bounds: bounds.translate(dx, dy),
821 owner,
822 },
823 Primitive::Path {
824 path,
825 fill,
826 gradient,
827 stroke,
828 clip,
829 owner,
830 } => Primitive::Path {
831 path: path.translated(dx, dy),
832 fill,
833 gradient: gradient.map(|g| g.translated(dx, dy)),
834 stroke,
835 clip: clip.translate(dx, dy),
836 owner,
837 },
838 Primitive::Image {
839 image,
840 rect,
841 uv,
842 tint,
843 strict,
844 clip,
845 owner,
846 } => Primitive::Image {
847 image,
848 rect: rect.translate(dx, dy),
849 uv,
850 tint,
851 strict,
852 clip: clip.translate(dx, dy),
853 owner,
854 },
855 Primitive::Layer {
856 primitives,
857 opacity,
858 clip,
859 clip_shape,
860 transform,
861 filter,
862 owner,
863 } => {
864 Primitive::Layer {
865 primitives: primitives.iter().map(|p| p.translated(dx, dy)).collect(),
866 opacity,
867 clip: clip.translate(dx, dy),
868 // Radius and ellipse are translation-invariant; only the `clip`
869 // rectangle moves, which is handled above.
870 clip_shape,
871 transform: transform.map(|t| t.translated(dx, dy)),
872 // A blur radius is a length, and does not move; a mask's geometry is
873 // held in the scene's coordinates once resolved from its box, and moves
874 // with what it covers (milestone 610: it was left behind).
875 filter: filter.translated(dx, dy),
876 owner,
877 }
878 }
879 }
880 }
881
882 /// This primitive's (approximate) bounding box, in scene coordinates.
883 /// Text is not measured in this module, so a `Text` or `RichText` primitive
884 /// returns a **point** rectangle at its position, enough as an x reference.
885 pub fn bounds(&self) -> Rect {
886 match self {
887 Primitive::Rect { rect, .. } | Primitive::Image { rect, .. } => *rect,
888 Primitive::Text { position, .. } | Primitive::RichText { position, .. } => {
889 Rect::new(position.x, position.y, 0.0, 0.0)
890 }
891 Primitive::Path { path, .. } => {
892 // The control points' bounding box — conservative bounds, since it
893 // encloses the Bézier controls and so is never smaller than the curve.
894 // Accumulated without allocating: min/max updated point by point.
895 let mut bbox: Option<(f32, f32, f32, f32)> = None;
896 let mut include = |p: Point| {
897 bbox = Some(match bbox {
898 None => (p.x, p.y, p.x, p.y),
899 Some((x0, y0, x1, y1)) => {
900 (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y))
901 }
902 });
903 };
904 for v in path.verbs() {
905 match *v {
906 PathVerb::MoveTo(p) | PathVerb::LineTo(p) => include(p),
907 PathVerb::QuadTo { ctrl, to } => {
908 include(ctrl);
909 include(to);
910 }
911 PathVerb::CubicTo { c1, c2, to } => {
912 include(c1);
913 include(c2);
914 include(to);
915 }
916 PathVerb::Close => {}
917 }
918 }
919 match bbox {
920 None => Rect::new(0.0, 0.0, 0.0, 0.0),
921 Some((x0, y0, x1, y1)) => Rect::new(x0, y0, x1 - x0, y1 - y0),
922 }
923 }
924 Primitive::Layer { primitives, .. } => primitives
925 .iter()
926 .map(|p| p.bounds())
927 .reduce(|a, b| a.union(b))
928 .unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)),
929 }
930 }
931
932 /// A copy of this primitive with its **clip** replaced by `clip` — used to
933 /// "un-clip" a captured primitive replayed elsewhere, such as the ghost of a
934 /// dragged header, which overflows its source column.
935 pub fn with_clip(&self, clip: Rect) -> Primitive {
936 match self.clone() {
937 Primitive::Rect {
938 rect,
939 color,
940 color2,
941 gradient_dir,
942 radius,
943 border_width,
944 border_color,
945 blur,
946 owner,
947 ..
948 } => Primitive::Rect {
949 rect,
950 color,
951 color2,
952 gradient_dir,
953 radius,
954 border_width,
955 border_color,
956 blur,
957 clip,
958 owner,
959 shader: None,
960 blur_style: crate::BlurStyle::Normal,
961 },
962 Primitive::Text {
963 position,
964 text,
965 size,
966 color,
967 weight,
968 italic,
969 max_width,
970 soft_wrap,
971 align,
972 decoration,
973 decoration_color,
974 height,
975 family,
976 letter_spacing,
977 bounds,
978 owner,
979 ..
980 } => Primitive::Text {
981 position,
982 text,
983 size,
984 color,
985 weight,
986 italic,
987 max_width,
988 soft_wrap,
989 align,
990 decoration,
991 decoration_color,
992 height,
993 family,
994 letter_spacing,
995 clip,
996 bounds,
997 owner,
998 },
999 Primitive::RichText {
1000 position,
1001 runs,
1002 max_width,
1003 soft_wrap,
1004 align,
1005 bounds,
1006 owner,
1007 ..
1008 } => Primitive::RichText {
1009 position,
1010 runs,
1011 max_width,
1012 soft_wrap,
1013 align,
1014 clip,
1015 bounds,
1016 owner,
1017 },
1018 Primitive::Path {
1019 path,
1020 fill,
1021 gradient,
1022 stroke,
1023 owner,
1024 ..
1025 } => Primitive::Path {
1026 path,
1027 fill,
1028 gradient,
1029 stroke,
1030 clip,
1031 owner,
1032 },
1033 Primitive::Image {
1034 image,
1035 rect,
1036 uv,
1037 tint,
1038 strict,
1039 owner,
1040 ..
1041 } => Primitive::Image {
1042 image,
1043 rect,
1044 uv,
1045 tint,
1046 strict,
1047 clip,
1048 owner,
1049 },
1050 Primitive::Layer {
1051 primitives,
1052 opacity,
1053 clip_shape,
1054 transform,
1055 filter,
1056 owner,
1057 ..
1058 } => Primitive::Layer {
1059 primitives,
1060 opacity,
1061 clip,
1062 clip_shape,
1063 transform,
1064 filter,
1065 owner,
1066 },
1067 }
1068 }
1069
1070 /// Scales the geometry by `factor` **about `pivot`**, which stays fixed:
1071 /// `pos' = pivot + (pos - pivot) * factor`. Sizes, font, radii and strokes all
1072 /// follow the scale.
1073 pub fn scaled_about(&self, pivot: Point, factor: f32) -> Primitive {
1074 self.scaled_about_xy(pivot, factor, factor)
1075 }
1076
1077 /// Like [`Primitive::scaled_about`], but with **per-axis** factors (`sx`, `sy`)
1078 /// — a non-uniform scale about `pivot`.
1079 pub fn scaled_about_xy(&self, pivot: Point, sx: f32, sy: f32) -> Primitive {
1080 self.scaled_xy(sx, sy)
1081 .translated(pivot.x * (1.0 - sx), pivot.y * (1.0 - sy))
1082 }
1083}
1084
1085/// The box a block of text is laid out in: how wide it is, whether the text wraps at
1086/// that width, and where the lines sit inside it. See [`Scene::text_block`].
1087#[derive(Clone, Copy, Debug, PartialEq, Default)]
1088pub struct TextBlock {
1089 /// The box's width; `None` leaves the text unbounded, and alignment with it.
1090 pub width: Option<f32>,
1091 /// Whether the text wraps at `width` rather than running past it.
1092 pub soft_wrap: bool,
1093 /// Where the lines sit inside `width`.
1094 pub align: TextAlign,
1095}
1096
1097/// A 2D scene: the declarative description of what to draw.
1098#[derive(Clone, Debug)]
1099pub struct Scene {
1100 primitives: Vec<Primitive>,
1101 current_clip: Rect,
1102 current_owner: u64,
1103 /// The box of the widget currently painting — what text primitives record so the
1104 /// renderer knows what they cover. `UNBOUNDED` until a walk sets it.
1105 current_bounds: Rect,
1106}
1107
1108impl Default for Scene {
1109 fn default() -> Self {
1110 Self {
1111 primitives: Vec::new(),
1112 current_clip: Rect::UNBOUNDED,
1113 current_owner: 0,
1114 current_bounds: Rect::UNBOUNDED,
1115 }
1116 }
1117}
1118
1119impl Scene {
1120 /// Creates an empty scene, with a neutral clip.
1121 pub fn new() -> Self {
1122 Self::default()
1123 }
1124
1125 /// Empties the scene so it can be reused on the next frame.
1126 pub fn clear(&mut self) {
1127 self.primitives.clear();
1128 self.current_clip = Rect::UNBOUNDED;
1129 self.current_owner = 0;
1130 self.current_bounds = Rect::UNBOUNDED;
1131 }
1132
1133 /// Sets the clip rectangle applied to subsequent primitives.
1134 pub fn set_clip(&mut self, clip: Rect) {
1135 self.current_clip = clip;
1136 }
1137
1138 /// The current clip rectangle, for intersecting with local bounds.
1139 pub fn current_clip(&self) -> Rect {
1140 self.current_clip
1141 }
1142
1143 /// Sets the emitting widget's identity for subsequent primitives.
1144 pub fn set_owner(&mut self, owner: u64) {
1145 self.current_owner = owner;
1146 }
1147
1148 /// The emitting widget's identity, as the next primitive will carry it.
1149 pub fn current_owner(&self) -> u64 {
1150 self.current_owner
1151 }
1152
1153 /// Declares the box the widget about to paint was laid out in. Text primitives
1154 /// record it, which is how the renderer knows what a line of text covers — a
1155 /// `Primitive::Text` otherwise says only where it begins. Set by the widget walk
1156 /// before every `paint`; a scene built by hand leaves it `UNBOUNDED`, and the
1157 /// renderer then keeps that text above everything, as it always did.
1158 pub fn set_bounds(&mut self, bounds: Rect) {
1159 self.current_bounds = bounds;
1160 }
1161
1162 /// The box currently being painted into.
1163 pub fn current_bounds(&self) -> Rect {
1164 self.current_bounds
1165 }
1166
1167 /// Appends an **already-formed** primitive, with its clip and owner already
1168 /// baked in. This replays a cached subtree (a repaint boundary) as-is, without
1169 /// repainting it.
1170 pub fn push_primitive(&mut self, primitive: Primitive) {
1171 self.primitives.push(primitive);
1172 }
1173
1174 /// Removes and returns the primitives from index `start` onwards, preserving
1175 /// order. This **wraps** an already-painted subtree in a layer
1176 /// ([`Primitive::Layer`]): paint the subtree, then move its range of primitives
1177 /// into a layer, giving it group opacity.
1178 pub fn split_off(&mut self, start: usize) -> Vec<Primitive> {
1179 self.primitives.split_off(start)
1180 }
1181
1182 /// Replays an existing primitive at reduced opacity — an exit fade.
1183 pub fn push_faded(&mut self, primitive: &Primitive, opacity: f32) {
1184 let faded = match primitive.clone() {
1185 Primitive::Rect {
1186 rect,
1187 color,
1188 color2,
1189 gradient_dir,
1190 radius,
1191 border_width,
1192 border_color,
1193 blur,
1194 clip,
1195 owner,
1196 shader,
1197 blur_style,
1198 } => Primitive::Rect {
1199 rect,
1200 color: color.fade(opacity),
1201 color2: color2.fade(opacity),
1202 gradient_dir,
1203 radius,
1204 border_width,
1205 border_color: border_color.fade(opacity),
1206 blur,
1207 clip,
1208 owner,
1209 shader: shader.map(|g| std::sync::Arc::new(g.scale(opacity))),
1210 blur_style,
1211 },
1212 Primitive::Text {
1213 position,
1214 text,
1215 size,
1216 color,
1217 weight,
1218 italic,
1219 max_width,
1220 soft_wrap,
1221 align,
1222 decoration,
1223 decoration_color,
1224 height,
1225 family,
1226 letter_spacing,
1227 clip,
1228 bounds,
1229 owner,
1230 } => Primitive::Text {
1231 position,
1232 text,
1233 size,
1234 color: color.fade(opacity),
1235 weight,
1236 italic,
1237 max_width,
1238 soft_wrap,
1239 align,
1240 decoration,
1241 decoration_color: decoration_color.map(|c| c.fade(opacity)),
1242 height,
1243 family,
1244 letter_spacing,
1245 clip,
1246 bounds,
1247 owner,
1248 },
1249 Primitive::RichText {
1250 position,
1251 mut runs,
1252 max_width,
1253 soft_wrap,
1254 align,
1255 clip,
1256 bounds,
1257 owner,
1258 } => {
1259 for run in &mut runs {
1260 run.color = run.color.fade(opacity);
1261 run.decoration_color = run.decoration_color.map(|c| c.fade(opacity));
1262 }
1263 Primitive::RichText {
1264 position,
1265 runs,
1266 max_width,
1267 soft_wrap,
1268 align,
1269 clip,
1270 bounds,
1271 owner,
1272 }
1273 }
1274 Primitive::Path {
1275 path,
1276 fill,
1277 gradient,
1278 stroke,
1279 clip,
1280 owner,
1281 } => Primitive::Path {
1282 path,
1283 fill: fill.map(|c| c.fade(opacity)),
1284 gradient: gradient.map(|g| g.faded(opacity)),
1285 stroke: stroke.map(|s| Stroke::new(s.color.fade(opacity), s.width)),
1286 clip,
1287 owner,
1288 },
1289 Primitive::Image {
1290 image,
1291 rect,
1292 uv,
1293 tint,
1294 strict,
1295 clip,
1296 owner,
1297 } => Primitive::Image {
1298 image,
1299 rect,
1300 uv,
1301 tint: tint.fade(opacity),
1302 strict,
1303 clip,
1304 owner,
1305 },
1306 Primitive::Layer {
1307 primitives,
1308 opacity: group,
1309 clip,
1310 clip_shape,
1311 transform,
1312 filter,
1313 owner,
1314 } => Primitive::Layer {
1315 primitives,
1316 // Fading a layer means dimming its group opacity.
1317 opacity: group * opacity,
1318 clip,
1319 clip_shape,
1320 transform,
1321 filter,
1322 owner,
1323 },
1324 };
1325 self.primitives.push(faded);
1326 }
1327
1328 /// Adds a solid rectangle, with square corners and no border.
1329 pub fn fill_rect(&mut self, rect: Rect, color: Color) {
1330 self.primitives.push(Primitive::Rect {
1331 rect,
1332 color,
1333 color2: color,
1334 gradient_dir: [0.0, 0.0],
1335 radius: BorderRadius::ZERO,
1336 border_width: 0.0,
1337 border_color: Color::TRANSPARENT,
1338 blur: 0.0,
1339 clip: self.current_clip,
1340 owner: self.current_owner,
1341 shader: None,
1342 blur_style: crate::BlurStyle::Normal,
1343 });
1344 }
1345
1346 /// Adds a rectangle with rounded corners (uniform through `f32`, or per corner
1347 /// through [`BorderRadius`]) and/or a border.
1348 /// **Fills a shape**, and draws its edge if it has one.
1349 ///
1350 /// Three of the four shapes are rounded rectangles once the box is known, so they go
1351 /// down the same path as [`Scene::draw_rect`] and cost nothing extra; a bevel becomes
1352 /// a filled path plus a stroked one. A caller who does this by hand at each site gets
1353 /// the fast path wrong somewhere, which is the whole reason this is one function.
1354 pub fn draw_shape(&mut self, rect: Rect, shape: ShapeBorder, fill: Color) {
1355 let side = shape.side();
1356 if let Some((box_, radius)) = shape.as_rounded(rect) {
1357 self.draw_rect(
1358 box_,
1359 fill,
1360 radius,
1361 if side.is_drawn() { side.width } else { 0.0 },
1362 side.color,
1363 );
1364 return;
1365 }
1366 let outline = shape.outline(rect);
1367 self.fill_path(&outline, fill);
1368 if side.is_drawn() {
1369 self.stroke_path(&outline, side.color, side.width);
1370 }
1371 }
1372
1373 /// Adds a flat-coloured rectangle, with an optional border.
1374 pub fn draw_rect(
1375 &mut self,
1376 rect: Rect,
1377 color: Color,
1378 radius: impl Into<BorderRadius>,
1379 border_width: f32,
1380 border_color: Color,
1381 ) {
1382 self.primitives.push(Primitive::Rect {
1383 rect,
1384 color,
1385 color2: color,
1386 gradient_dir: [0.0, 0.0],
1387 radius: radius.into(),
1388 border_width,
1389 border_color,
1390 blur: 0.0,
1391 clip: self.current_clip,
1392 owner: self.current_owner,
1393 shader: None,
1394 blur_style: crate::BlurStyle::Normal,
1395 });
1396 }
1397
1398 /// **A rectangle filled with a gradient** (milestone 644): any of the reference's
1399 /// three, relative to `rect`, with corners and a border as [`Scene::draw_rect`] has
1400 /// them.
1401 #[allow(clippy::too_many_arguments)]
1402 pub fn shaded_rect(
1403 &mut self,
1404 rect: Rect,
1405 gradient: crate::Gradient,
1406 opacity: f32,
1407 radius: impl Into<BorderRadius>,
1408 border_width: f32,
1409 border_color: Color,
1410 ) {
1411 let gradient = if opacity < 1.0 {
1412 gradient.scale(opacity)
1413 } else {
1414 gradient
1415 };
1416 let first = gradient
1417 .colors()
1418 .first()
1419 .copied()
1420 .unwrap_or(Color::TRANSPARENT);
1421 let last = gradient
1422 .colors()
1423 .last()
1424 .copied()
1425 .unwrap_or(Color::TRANSPARENT);
1426 self.primitives.push(Primitive::Rect {
1427 rect,
1428 color: first,
1429 color2: last,
1430 gradient_dir: [0.0, 0.0],
1431 radius: radius.into(),
1432 border_width,
1433 border_color,
1434 blur: 0.0,
1435 clip: self.current_clip,
1436 owner: self.current_owner,
1437 shader: Some(std::sync::Arc::new(gradient)),
1438 blur_style: crate::BlurStyle::Normal,
1439 });
1440 }
1441
1442 /// Adds a linear-gradient rectangle (`color` → `color2` along `dir`).
1443 // Eight, and every one of them a property of the same rectangle. Grouping them
1444 // into a struct is an API change with call sites, not a lint fix.
1445 #[allow(clippy::too_many_arguments)]
1446 pub fn gradient_rect(
1447 &mut self,
1448 rect: Rect,
1449 color: Color,
1450 color2: Color,
1451 dir: [f32; 2],
1452 radius: impl Into<BorderRadius>,
1453 border_width: f32,
1454 border_color: Color,
1455 ) {
1456 self.primitives.push(Primitive::Rect {
1457 rect,
1458 color,
1459 color2,
1460 gradient_dir: dir,
1461 radius: radius.into(),
1462 border_width,
1463 border_color,
1464 blur: 0.0,
1465 clip: self.current_clip,
1466 owner: self.current_owner,
1467 shader: None,
1468 blur_style: crate::BlurStyle::Normal,
1469 });
1470 }
1471
1472 /// Adds a soft shadow — a rounded rectangle with a blurred edge, no border.
1473 pub fn shadow(&mut self, rect: Rect, color: Color, radius: impl Into<BorderRadius>, blur: f32) {
1474 self.styled_shadow(rect, color, radius, blur, BlurStyle::Normal);
1475 }
1476
1477 /// Adds a soft shadow whose blur keeps one side of the shape's edge, or both
1478 /// ([`BlurStyle`]). As [`Self::shadow`], `rect` already reaches `blur` beyond the
1479 /// shape, which runs `blur` inside it.
1480 pub fn styled_shadow(
1481 &mut self,
1482 rect: Rect,
1483 color: Color,
1484 radius: impl Into<BorderRadius>,
1485 blur: f32,
1486 blur_style: BlurStyle,
1487 ) {
1488 self.primitives.push(Primitive::Rect {
1489 rect,
1490 color,
1491 color2: color,
1492 gradient_dir: [0.0, 0.0],
1493 radius: radius.into(),
1494 border_width: 0.0,
1495 border_color: Color::TRANSPARENT,
1496 blur,
1497 clip: self.current_clip,
1498 owner: self.current_owner,
1499 shader: None,
1500 blur_style,
1501 });
1502 }
1503
1504 /// Fills a vector path with a flat colour, using the *non-zero* rule.
1505 pub fn fill_path(&mut self, path: &Path, color: Color) {
1506 self.primitives.push(Primitive::Path {
1507 path: path.clone(),
1508 fill: Some(color),
1509 gradient: None,
1510 stroke: None,
1511 clip: self.current_clip,
1512 owner: self.current_owner,
1513 });
1514 }
1515
1516 /// Fills a path with a **linear gradient**: `from_color` at `from`, `to_color` at
1517 /// `to`, clamped outside that span.
1518 ///
1519 /// The two points are in the path's own space rather than relative to its box, so
1520 /// the fade can be aimed at the thing it is about — the edge a glow springs from,
1521 /// the depth of a band — which is what an ellipse cap needs, its bounding box
1522 /// being mostly off screen.
1523 pub fn fill_path_gradient(
1524 &mut self,
1525 path: &Path,
1526 from_color: Color,
1527 to_color: Color,
1528 from: Point,
1529 to: Point,
1530 ) {
1531 self.primitives.push(Primitive::Path {
1532 path: path.clone(),
1533 fill: Some(from_color),
1534 gradient: Some(PathGradient::Linear { to_color, from, to }),
1535 stroke: None,
1536 clip: self.current_clip,
1537 owner: self.current_owner,
1538 });
1539 }
1540
1541 /// Fills a path with a **radial gradient**: `from_color` on the ellipse of radii
1542 /// `inner × radii` about `center`, `to_color` on the ellipse of radii `radii`.
1543 ///
1544 /// This is what a shape with a **curved** boundary needs in order to fade to
1545 /// nothing all the way round: a straight fade reaches zero on a line, so it still
1546 /// leaves an edge wherever the shape turns away from that line.
1547 pub fn fill_path_radial(
1548 &mut self,
1549 path: &Path,
1550 from_color: Color,
1551 to_color: Color,
1552 center: Point,
1553 radii: Size,
1554 inner: f32,
1555 ) {
1556 self.primitives.push(Primitive::Path {
1557 path: path.clone(),
1558 fill: Some(from_color),
1559 gradient: Some(PathGradient::Radial {
1560 to_color,
1561 center,
1562 radii,
1563 inner,
1564 }),
1565 stroke: None,
1566 clip: self.current_clip,
1567 owner: self.current_owner,
1568 });
1569 }
1570
1571 /// Fills a path with a [`GradientRamp`] — any number of stops, along a line or between
1572 /// two circles, turned or skewed by its own transform.
1573 pub fn fill_path_ramp(&mut self, path: &Path, ramp: GradientRamp, opacity: f32) {
1574 self.primitives.push(Primitive::Path {
1575 path: path.clone(),
1576 fill: Some(Color::WHITE.fade(opacity)),
1577 gradient: Some(PathGradient::Ramp(Arc::new(ramp))),
1578 stroke: None,
1579 clip: self.current_clip,
1580 owner: self.current_owner,
1581 });
1582 }
1583
1584 /// Strokes a path's outline (colour plus width), with no fill.
1585 pub fn stroke_path(&mut self, path: &Path, color: Color, width: f32) {
1586 self.primitives.push(Primitive::Path {
1587 path: path.clone(),
1588 fill: None,
1589 gradient: None,
1590 stroke: Some(Stroke::new(color, width)),
1591 clip: self.current_clip,
1592 owner: self.current_owner,
1593 });
1594 }
1595
1596 /// Adds a path filled **and/or** stroked — both passes in one primitive.
1597 pub fn paint_path(&mut self, path: &Path, fill: Option<Color>, stroke: Option<Stroke>) {
1598 self.primitives.push(Primitive::Path {
1599 path: path.clone(),
1600 fill,
1601 gradient: None,
1602 stroke,
1603 clip: self.current_clip,
1604 owner: self.current_owner,
1605 });
1606 }
1607
1608 /// Draws an image into `rect`, sampling the `uv` sub-region (in `0..1`) and
1609 /// tinting it by `tint` (white = unchanged). Low level: see [`Scene::image`]
1610 /// for automatic fitting through [`crate::BoxFit`].
1611 pub fn draw_image(&mut self, image: &ImageHandle, rect: Rect, uv: Rect, tint: Color) {
1612 self.push_image(image, rect, uv, tint, false);
1613 }
1614
1615 /// [`Scene::draw_image`], sampled **strictly** inside `uv`: no filtered sample
1616 /// reaches the texels around it. For one part of a picture stretched apart from the
1617 /// rest, as a centre slice's are.
1618 pub fn draw_image_strict(&mut self, image: &ImageHandle, rect: Rect, uv: Rect, tint: Color) {
1619 self.push_image(image, rect, uv, tint, true);
1620 }
1621
1622 fn push_image(&mut self, image: &ImageHandle, rect: Rect, uv: Rect, tint: Color, strict: bool) {
1623 self.primitives.push(Primitive::Image {
1624 image: image.clone(),
1625 rect,
1626 uv,
1627 tint,
1628 strict,
1629 clip: self.current_clip,
1630 owner: self.current_owner,
1631 });
1632 }
1633
1634 /// Draws a whole image **through an affine map** from its pixels — `(0, 0)` to
1635 /// `(width, height)` — to the screen, tinted by `tint`: a bitmap inside vector
1636 /// artwork, which may be turned, slanted or mirrored with it (milestone 694).
1637 ///
1638 /// A map that only scales and moves is one image primitive. Any other draws the image
1639 /// upright at its size on screen, at the origin, in a [transformed
1640 /// layer](Scene::transformed) that turns it into place, so it is sampled once at the
1641 /// resolution it is shown at. Like any transformed layer, an image larger than the
1642 /// window once on screen loses what lies past the window's size.
1643 pub fn draw_image_mapped(&mut self, image: &ImageHandle, to_screen: Affine, tint: Color) {
1644 let (w, h) = (image.width() as f32, image.height() as f32);
1645 let [a, b, c, d, e, f] = to_screen.m;
1646 if w == 0.0 || h == 0.0 || !to_screen.m.iter().all(|v| v.is_finite()) {
1647 return;
1648 }
1649 let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
1650 if b == 0.0 && c == 0.0 && a > 0.0 && d > 0.0 {
1651 self.draw_image(image, Rect::new(e, f, a * w, d * h), whole, tint);
1652 return;
1653 }
1654 // The length each pixel's sides take on screen.
1655 let (sx, sy) = (a.hypot(b), c.hypot(d));
1656 if sx == 0.0 || sy == 0.0 {
1657 return;
1658 }
1659 let flat = Rect::new(0.0, 0.0, w * sx, h * sy);
1660 let turn = Affine::scale(1.0 / sx, 1.0 / sy).then(to_screen);
1661 self.transformed(LayerTransform::new(turn), |scene| {
1662 // The clip around it is the screen's; flat, the image is its own.
1663 scene.set_clip(flat);
1664 scene.draw_image(image, flat, whole, tint);
1665 });
1666 }
1667
1668 /// Draws an image fitted into `rect` per `fit` (aspect preserved, letterboxed
1669 /// or cropped), untinted.
1670 pub fn image(&mut self, image: &ImageHandle, rect: Rect, fit: crate::BoxFit) {
1671 let (dst, uv) = fit.apply(image.size(), rect);
1672 self.draw_image(image, dst, uv, Color::WHITE);
1673 }
1674
1675 /// Composes a **layer**: `build` fills a subgroup of primitives, which is then
1676 /// composited **as one** at `opacity` (`0..1`). Unlike an opacity applied
1677 /// primitive by primitive, the group alpha stays correct where the inner
1678 /// primitives overlap. The layer inherits the current clip and owner.
1679 pub fn layer(&mut self, opacity: f32, build: impl FnOnce(&mut Scene)) {
1680 let mut inner = Scene::new();
1681 inner.current_clip = self.current_clip;
1682 inner.current_owner = self.current_owner;
1683 build(&mut inner);
1684 self.primitives.push(Primitive::Layer {
1685 primitives: inner.primitives,
1686 opacity,
1687 clip: self.current_clip,
1688 clip_shape: ClipShape::Rect,
1689 transform: None,
1690 filter: LayerFilter::NONE,
1691 owner: self.current_owner,
1692 });
1693 }
1694
1695 /// Composes a **transformed layer**: `build` fills a subgroup of primitives, which is
1696 /// rendered flat and then composited through `transform`.
1697 ///
1698 /// The transform belongs to the group and not to the primitives inside it, which is
1699 /// the whole point: a rotation applies to a rectangle, a line of text and an image at
1700 /// once, and none of them has to know how to rotate itself.
1701 ///
1702 /// **Paint the group where it fits, not where it lands.** The renderer draws the
1703 /// group flat into a *window-sized* texture and composites that through the
1704 /// transform, so anything laid out flat beyond the window is gone before the
1705 /// transform could have brought it back inside. A group that ends up near an edge
1706 /// should be built at the origin and carried there by the transform.
1707 pub fn transformed(&mut self, transform: LayerTransform, build: impl FnOnce(&mut Scene)) {
1708 let mut inner = Scene::new();
1709 inner.current_clip = self.current_clip;
1710 inner.current_owner = self.current_owner;
1711 inner.current_bounds = self.current_bounds;
1712 build(&mut inner);
1713 self.primitives.push(Primitive::Layer {
1714 primitives: inner.primitives,
1715 opacity: 1.0,
1716 clip: self.current_clip,
1717 clip_shape: ClipShape::Rect,
1718 transform: Some(transform),
1719 filter: LayerFilter::NONE,
1720 owner: self.current_owner,
1721 });
1722 }
1723
1724 /// Adds a **masked group**: everything `build` emits is composited as one, with
1725 /// `mask` multiplied into it, so the shader's alpha becomes the group's transparency.
1726 ///
1727 /// A fade has to be a group. Applied primitive by primitive it would fade each of
1728 /// them against the background separately, and where two of them overlap the result
1729 /// is neither one nor the other.
1730 pub fn masked(&mut self, mask: ShaderMask, build: impl FnOnce(&mut Scene)) {
1731 let mut inner = Scene::new();
1732 inner.current_clip = self.current_clip;
1733 inner.current_owner = self.current_owner;
1734 inner.current_bounds = self.current_bounds;
1735 build(&mut inner);
1736 self.primitives.push(Primitive::Layer {
1737 primitives: inner.primitives,
1738 opacity: 1.0,
1739 clip: self.current_clip,
1740 clip_shape: ClipShape::Rect,
1741 transform: None,
1742 filter: LayerFilter {
1743 mask: Some(mask),
1744 ..LayerFilter::NONE
1745 },
1746 owner: self.current_owner,
1747 });
1748 }
1749
1750 /// Adds a **blended group** (milestone 646): everything `build` emits is composited
1751 /// as one, meeting what is already painted underneath by `mode` rather than being
1752 /// painted over it. The group's clip is where it applies; outside it, what was
1753 /// there stays.
1754 pub fn blended(&mut self, mode: crate::BlendMode, build: impl FnOnce(&mut Scene)) {
1755 self.blended_in(mode, ClipShape::Rect, build);
1756 }
1757
1758 /// [`Scene::blended`] in a `shape` inscribed in the current clip: rounded corners or
1759 /// an ellipse, or a path in absolute coordinates.
1760 pub fn blended_in(
1761 &mut self,
1762 mode: crate::BlendMode,
1763 shape: ClipShape,
1764 build: impl FnOnce(&mut Scene),
1765 ) {
1766 let mut inner = Scene::new();
1767 inner.current_clip = self.current_clip;
1768 inner.current_owner = self.current_owner;
1769 inner.current_bounds = self.current_bounds;
1770 build(&mut inner);
1771 self.primitives.push(Primitive::Layer {
1772 primitives: inner.primitives,
1773 opacity: 1.0,
1774 clip: self.current_clip,
1775 clip_shape: shape,
1776 transform: None,
1777 filter: LayerFilter {
1778 blend: Some(mode),
1779 ..LayerFilter::NONE
1780 },
1781 owner: self.current_owner,
1782 });
1783 }
1784
1785 /// Adds **rich** text: resolved runs, mixing styles and colours, laid out as one
1786 /// piece, anchored by its top-left corner.
1787 pub fn rich_text(&mut self, position: Point, runs: Vec<TextRun>) {
1788 self.primitives.push(Primitive::RichText {
1789 position,
1790 runs,
1791 max_width: None,
1792 soft_wrap: true,
1793 align: TextAlign::Start,
1794 clip: self.current_clip,
1795 bounds: self.current_bounds,
1796 owner: self.current_owner,
1797 });
1798 }
1799
1800 /// Adds a **rich paragraph**: the runs wrap beyond `max_width`, and the render's
1801 /// wrapping matches the layout's.
1802 pub fn rich_text_wrapped(&mut self, position: Point, runs: Vec<TextRun>, max_width: f32) {
1803 self.primitives.push(Primitive::RichText {
1804 position,
1805 runs,
1806 max_width: Some(max_width),
1807 soft_wrap: true,
1808 align: TextAlign::Start,
1809 clip: self.current_clip,
1810 bounds: self.current_bounds,
1811 owner: self.current_owner,
1812 });
1813 }
1814
1815 /// Adds a line of text, anchored by its top-left corner — size, weight and italics
1816 /// from the [`ResolvedTextStyle`].
1817 ///
1818 /// It takes a **resolved** style and not a [`TextStyle`](crate::TextStyle) because
1819 /// this is the bottom of the chain: a shaper needs a number and there is nobody left
1820 /// below to ask one of. `color` is settled the same way, by the caller, usually
1821 /// against the theme — which is the one answer this crate cannot work out for itself.
1822 ///
1823 /// There is deliberately **no form of this that takes a bare `f32` size**. A number
1824 /// arriving here has been through [`TextStyle::resolved`](crate::TextStyle::resolved),
1825 /// which is the single place the reader's font size is applied; a second door taking a
1826 /// raw constant is a door onto text that ignores the reader, and forty-seven call sites
1827 /// walked through it (milestone 406).
1828 pub fn text(
1829 &mut self,
1830 position: Point,
1831 text: impl Into<String>,
1832 style: &ResolvedTextStyle,
1833 color: Color,
1834 ) {
1835 self.primitives.push(Primitive::Text {
1836 position,
1837 text: text.into(),
1838 size: style.size,
1839 color,
1840 weight: style.weight,
1841 italic: style.italic,
1842 max_width: None,
1843 soft_wrap: true,
1844 align: TextAlign::Start,
1845 decoration: style.decoration,
1846 decoration_color: style.decoration_color,
1847 height: style.height,
1848 family: style.family,
1849 letter_spacing: style.letter_spacing,
1850 clip: self.current_clip,
1851 bounds: self.current_bounds,
1852 owner: self.current_owner,
1853 });
1854 }
1855
1856 /// Adds a **paragraph**: styled text that wraps beyond `max_width`, the render's
1857 /// wrapping matching the layout's.
1858 pub fn text_wrapped(
1859 &mut self,
1860 position: Point,
1861 text: impl Into<String>,
1862 style: &ResolvedTextStyle,
1863 color: Color,
1864 max_width: f32,
1865 ) {
1866 self.primitives.push(Primitive::Text {
1867 position,
1868 text: text.into(),
1869 size: style.size,
1870 color,
1871 weight: style.weight,
1872 italic: style.italic,
1873 max_width: Some(max_width),
1874 soft_wrap: true,
1875 align: TextAlign::Start,
1876 decoration: style.decoration,
1877 decoration_color: style.decoration_color,
1878 height: style.height,
1879 family: style.family,
1880 letter_spacing: style.letter_spacing,
1881 clip: self.current_clip,
1882 bounds: self.current_bounds,
1883 owner: self.current_owner,
1884 });
1885 }
1886
1887 /// Adds a **block** of styled text: a box of a known width, which the text may be
1888 /// wrapped in and is aligned inside.
1889 ///
1890 /// The three settings travel together because they are one decision. A width with no
1891 /// wrapping is a line that knows where its box ends and runs past it anyway — which is
1892 /// what a clipped or ellipsised line is — and an alignment with no width has nothing
1893 /// to align against.
1894 pub fn text_block(
1895 &mut self,
1896 position: Point,
1897 text: impl Into<String>,
1898 style: &ResolvedTextStyle,
1899 color: Color,
1900 block: TextBlock,
1901 ) {
1902 self.primitives.push(Primitive::Text {
1903 position,
1904 text: text.into(),
1905 size: style.size,
1906 color,
1907 weight: style.weight,
1908 italic: style.italic,
1909 max_width: block.width,
1910 soft_wrap: block.soft_wrap,
1911 align: block.align,
1912 decoration: style.decoration,
1913 decoration_color: style.decoration_color,
1914 height: style.height,
1915 family: style.family,
1916 letter_spacing: style.letter_spacing,
1917 clip: self.current_clip,
1918 bounds: self.current_bounds,
1919 owner: self.current_owner,
1920 });
1921 }
1922
1923 /// Adds a **block** of rich text: resolved runs in a box of a known width, which they
1924 /// may be wrapped in and are aligned inside. The rich counterpart of
1925 /// [`Scene::text_block`].
1926 pub fn rich_text_block(&mut self, position: Point, runs: Vec<TextRun>, block: TextBlock) {
1927 self.primitives.push(Primitive::RichText {
1928 position,
1929 runs,
1930 max_width: block.width,
1931 soft_wrap: block.soft_wrap,
1932 align: block.align,
1933 clip: self.current_clip,
1934 bounds: self.current_bounds,
1935 owner: self.current_owner,
1936 });
1937 }
1938
1939 /// The number of primitives in the scene.
1940 pub fn len(&self) -> usize {
1941 self.primitives.len()
1942 }
1943
1944 /// `true` when the scene holds no primitives at all.
1945 pub fn is_empty(&self) -> bool {
1946 self.primitives.is_empty()
1947 }
1948
1949 /// The primitives, in insertion order, which is drawing order.
1950 pub fn primitives(&self) -> &[Primitive] {
1951 &self.primitives
1952 }
1953
1954 /// A copy of the scene with all geometry scaled by `factor` — the logical →
1955 /// physical conversion for HiDPI rendering.
1956 pub fn scaled(&self, factor: f32) -> Scene {
1957 Scene {
1958 primitives: self.primitives.iter().map(|p| p.scaled(factor)).collect(),
1959 current_clip: self.current_clip.scale(factor),
1960 current_owner: self.current_owner,
1961 current_bounds: self.current_bounds.scale(factor),
1962 }
1963 }
1964}
1965
1966#[cfg(test)]
1967mod tests {
1968 use super::*;
1969
1970 fn close(a: Option<f32>, b: f32) -> bool {
1971 a.is_some_and(|a| (a - b).abs() < 1e-4)
1972 }
1973
1974 fn ramp(shape: RampShape, tile: TileMode) -> GradientRamp {
1975 GradientRamp {
1976 shape,
1977 stops: vec![
1978 (0.0, Color::rgb(1.0, 0.0, 0.0)),
1979 (0.5, Color::rgb(0.0, 1.0, 0.0)),
1980 (1.0, Color::rgb(0.0, 0.0, 1.0)),
1981 ],
1982 tile,
1983 transform: Affine::default(),
1984 }
1985 }
1986
1987 const LINE: RampShape = RampShape::Linear {
1988 from: Point::new(10.0, 0.0),
1989 to: Point::new(30.0, 0.0),
1990 };
1991
1992 /// **Along a line**: the offset is the distance along it, the same across it, and the
1993 /// ends do what the tile mode says.
1994 #[test]
1995 fn a_linear_ramp_runs_along_its_line_and_tiles_past_it() {
1996 let clamp = ramp(LINE, TileMode::Clamp);
1997 assert!(close(clamp.t_at(Point::new(15.0, 0.0)), 0.25));
1998 assert!(
1999 close(clamp.t_at(Point::new(15.0, 99.0)), 0.25),
2000 "constant across"
2001 );
2002 assert!(close(clamp.t_at(Point::new(50.0, 0.0)), 1.0));
2003 let repeat = ramp(LINE, TileMode::Repeated);
2004 assert!(close(repeat.t_at(Point::new(35.0, 0.0)), 0.25));
2005 let mirror = ramp(LINE, TileMode::Mirror);
2006 assert!(close(mirror.t_at(Point::new(35.0, 0.0)), 0.75));
2007 let decal = ramp(LINE, TileMode::Decal);
2008 assert_eq!(
2009 decal.t_at(Point::new(35.0, 0.0)),
2010 None,
2011 "nothing past a decal's end"
2012 );
2013 assert_eq!(decal.color_at(Point::new(35.0, 0.0)), Color::TRANSPARENT);
2014 }
2015
2016 /// **Between the stops**, the mix of the two either side; before and after, the end
2017 /// colours.
2018 #[test]
2019 fn a_ramp_mixes_the_stops_either_side() {
2020 let r = ramp(LINE, TileMode::Clamp);
2021 assert_eq!(r.sample(-1.0), Color::rgb(1.0, 0.0, 0.0));
2022 assert_eq!(r.sample(0.5), Color::rgb(0.0, 1.0, 0.0));
2023 let quarter = r.sample(0.25);
2024 assert!((quarter.r - 0.5).abs() < 1e-6 && (quarter.g - 0.5).abs() < 1e-6);
2025 assert_eq!(r.sample(2.0), Color::rgb(0.0, 0.0, 1.0));
2026 }
2027
2028 /// **Radial**: the distance from the centre in radii, and, with a focal point off the
2029 /// centre, the circle through the point — nearer on the focal side.
2030 #[test]
2031 fn a_radial_ramp_runs_outward_and_bends_towards_its_focal_point() {
2032 let centred = ramp(
2033 RampShape::Radial {
2034 center: Point::new(0.0, 0.0),
2035 radius: 10.0,
2036 focal: Point::new(0.0, 0.0),
2037 focal_radius: 0.0,
2038 },
2039 TileMode::Clamp,
2040 );
2041 assert!(close(centred.t_at(Point::new(5.0, 0.0)), 0.5));
2042 assert!(
2043 close(centred.t_at(Point::new(0.0, -5.0)), 0.5),
2044 "the same all round"
2045 );
2046 let focal = ramp(
2047 RampShape::Radial {
2048 center: Point::new(0.0, 0.0),
2049 radius: 10.0,
2050 focal: Point::new(5.0, 0.0),
2051 focal_radius: 0.0,
2052 },
2053 TileMode::Clamp,
2054 );
2055 // At the focal point, the start; on the outer circle, the end, either side.
2056 assert!(close(focal.t_at(Point::new(5.0, 0.0)), 0.0));
2057 assert!(close(focal.t_at(Point::new(10.0, 0.0)), 1.0));
2058 assert!(close(focal.t_at(Point::new(-10.0, 0.0)), 1.0));
2059 // Halfway from the focal point to each side's edge is halfway through.
2060 assert!(close(focal.t_at(Point::new(7.5, 0.0)), 0.5));
2061 assert!(close(focal.t_at(Point::new(-2.5, 0.0)), 0.5));
2062 }
2063
2064 /// **A transformed gradient is drawn exactly**: the point is mapped back into the
2065 /// gradient's own space, so a turned line runs the turned way.
2066 #[test]
2067 fn a_ramp_follows_its_transform() {
2068 let mut turned = ramp(LINE, TileMode::Clamp);
2069 // A quarter turn about the origin: the line now runs down the y axis.
2070 turned.transform = Affine::rotation(std::f32::consts::FRAC_PI_2);
2071 assert!(close(turned.t_at(Point::new(0.0, 15.0)), 0.25));
2072 assert!(
2073 close(turned.t_at(Point::new(40.0, 15.0)), 0.25),
2074 "constant across it"
2075 );
2076 }
2077
2078 /// **A scaled or moved path takes its gradient with it**: the colour at a point of the
2079 /// moved shape is the colour the same point had before.
2080 #[test]
2081 fn a_ramp_moves_and_scales_with_its_primitive() {
2082 let mut scene = Scene::new();
2083 let shape = Path::rect(Rect::new(10.0, 0.0, 20.0, 10.0));
2084 scene.fill_path_ramp(&shape, ramp(LINE, TileMode::Clamp), 1.0);
2085 let gradient_of = |p: &Primitive| match p {
2086 Primitive::Path {
2087 gradient: Some(PathGradient::Ramp(r)),
2088 ..
2089 } => r.clone(),
2090 _ => panic!("a ramp"),
2091 };
2092 let before = gradient_of(&scene.primitives()[0]);
2093 let scaled = gradient_of(&scene.primitives()[0].scaled_xy(2.0, 3.0));
2094 assert!(close(
2095 scaled.t_at(Point::new(30.0, 6.0)),
2096 before.t_at(Point::new(15.0, 2.0)).unwrap()
2097 ));
2098 let moved = gradient_of(&scene.primitives()[0].translated(5.0, 7.0));
2099 assert!(close(moved.t_at(Point::new(20.0, 7.0)), 0.25));
2100 }
2101 use crate::{Color, Rect};
2102
2103 #[test]
2104 fn fill_rect_pushes_primitive_with_current_clip() {
2105 let mut scene = Scene::new();
2106 assert!(scene.is_empty());
2107 scene.fill_rect(Rect::new(1.0, 2.0, 3.0, 4.0), Color::WHITE);
2108 assert_eq!(scene.len(), 1);
2109 assert_eq!(
2110 scene.primitives()[0],
2111 Primitive::Rect {
2112 rect: Rect::new(1.0, 2.0, 3.0, 4.0),
2113 color: Color::WHITE,
2114 color2: Color::WHITE,
2115 gradient_dir: [0.0, 0.0],
2116 radius: BorderRadius::ZERO,
2117 border_width: 0.0,
2118 border_color: Color::TRANSPARENT,
2119 blur: 0.0,
2120 clip: Rect::UNBOUNDED,
2121 owner: 0,
2122 shader: None,
2123 blur_style: crate::BlurStyle::Normal,
2124 }
2125 );
2126 }
2127
2128 /// A gradient describes geometry, so it has to move with the geometry it
2129 /// describes — otherwise a fade aimed at the top of a list would stay behind when
2130 /// the DPI scale or a layer offset moved the shape.
2131 #[test]
2132 fn a_radial_gradient_travels_with_its_shape() {
2133 let mut scene = Scene::new();
2134 scene.fill_path_radial(
2135 &Path::oval(Rect::new(0.0, 0.0, 40.0, 20.0)),
2136 Color::WHITE,
2137 Color::TRANSPARENT,
2138 Point::new(20.0, 10.0),
2139 Size::new(20.0, 10.0),
2140 0.8,
2141 );
2142 let moved = scene.primitives()[0].clone().scaled_xy(2.0, 3.0);
2143 let Primitive::Path { gradient, .. } = moved.translated(5.0, 7.0) else {
2144 panic!("a filled path");
2145 };
2146 assert_eq!(
2147 gradient,
2148 Some(PathGradient::Radial {
2149 to_color: Color::TRANSPARENT,
2150 center: Point::new(45.0, 37.0),
2151 radii: Size::new(40.0, 30.0),
2152 // The rind is a ratio, so it is the one thing scaling must not touch.
2153 inner: 0.8,
2154 })
2155 );
2156 assert_eq!(gradient.unwrap().faded(0.5).to_color().a, 0.0);
2157 }
2158
2159 #[test]
2160 fn push_faded_scales_alpha_and_keeps_owner() {
2161 let mut scene = Scene::new();
2162 scene.set_owner(42);
2163 scene.fill_rect(
2164 Rect::new(0.0, 0.0, 1.0, 1.0),
2165 Color::rgba(1.0, 0.0, 0.0, 1.0),
2166 );
2167 let source = scene.primitives()[0].clone();
2168 assert_eq!(source.owner(), 42);
2169
2170 let mut target = Scene::new();
2171 target.push_faded(&source, 0.5);
2172 if let Primitive::Rect { color, owner, .. } = target.primitives()[0] {
2173 assert_eq!(color.a, 0.5);
2174 assert_eq!(owner, 42);
2175 } else {
2176 panic!("expected a rect");
2177 }
2178 }
2179
2180 #[test]
2181 fn scaled_multiplies_geometry_not_colors() {
2182 let mut scene = Scene::new();
2183 scene.draw_rect(
2184 Rect::new(2.0, 4.0, 10.0, 20.0),
2185 Color::rgb(1.0, 0.0, 0.0),
2186 3.0,
2187 1.0,
2188 Color::WHITE,
2189 );
2190 scene.text(
2191 Point::new(5.0, 6.0),
2192 "hi",
2193 &ResolvedTextStyle::exact(18.0),
2194 Color::BLACK,
2195 );
2196
2197 let big = scene.scaled(2.0);
2198 match &big.primitives()[0] {
2199 Primitive::Rect {
2200 rect,
2201 radius,
2202 border_width,
2203 color,
2204 ..
2205 } => {
2206 assert_eq!(*rect, Rect::new(4.0, 8.0, 20.0, 40.0));
2207 assert_eq!(*radius, BorderRadius::uniform(6.0));
2208 assert_eq!(*border_width, 2.0);
2209 assert_eq!(*color, Color::rgb(1.0, 0.0, 0.0)); // colour unchanged
2210 }
2211 _ => panic!("expected a rect"),
2212 }
2213 match &big.primitives()[1] {
2214 Primitive::Text {
2215 position,
2216 size,
2217 text,
2218 ..
2219 } => {
2220 assert_eq!(*position, Point::new(10.0, 12.0));
2221 assert_eq!(*size, 36.0);
2222 assert_eq!(text, "hi"); // text unchanged
2223 }
2224 _ => panic!("expected text"),
2225 }
2226 }
2227
2228 #[test]
2229 fn layer_captures_subprimitives_and_opacity() {
2230 let mut scene = Scene::new();
2231 scene.set_owner(7);
2232 scene.set_clip(Rect::new(0.0, 0.0, 50.0, 50.0));
2233 scene.layer(0.5, |inner| {
2234 inner.fill_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Color::WHITE);
2235 inner.fill_rect(Rect::new(5.0, 5.0, 10.0, 10.0), Color::BLACK);
2236 });
2237 assert_eq!(scene.len(), 1);
2238 match &scene.primitives()[0] {
2239 Primitive::Layer {
2240 primitives,
2241 opacity,
2242 clip,
2243 owner,
2244 ..
2245 } => {
2246 assert_eq!(primitives.len(), 2);
2247 assert_eq!(*opacity, 0.5);
2248 assert_eq!(*clip, Rect::new(0.0, 0.0, 50.0, 50.0));
2249 assert_eq!(*owner, 7);
2250 }
2251 _ => panic!("expected a layer"),
2252 }
2253 }
2254
2255 #[test]
2256 fn fading_a_layer_scales_its_group_opacity() {
2257 let mut scene = Scene::new();
2258 scene.layer(0.8, |inner| {
2259 inner.fill_rect(Rect::new(0.0, 0.0, 1.0, 1.0), Color::WHITE)
2260 });
2261 let layer = scene.primitives()[0].clone();
2262 let mut target = Scene::new();
2263 target.push_faded(&layer, 0.5);
2264 match &target.primitives()[0] {
2265 Primitive::Layer { opacity, .. } => assert!((*opacity - 0.4).abs() < 1e-6),
2266 _ => panic!("expected a layer"),
2267 }
2268 }
2269
2270 #[test]
2271 fn scaling_a_layer_scales_its_children() {
2272 let mut scene = Scene::new();
2273 scene.layer(1.0, |inner| {
2274 inner.fill_rect(Rect::new(2.0, 3.0, 4.0, 5.0), Color::WHITE)
2275 });
2276 let big = scene.scaled(2.0);
2277 match &big.primitives()[0] {
2278 Primitive::Layer { primitives, .. } => match &primitives[0] {
2279 Primitive::Rect { rect, .. } => assert_eq!(*rect, Rect::new(4.0, 6.0, 8.0, 10.0)),
2280 _ => panic!("expected a rect"),
2281 },
2282 _ => panic!("expected a layer"),
2283 }
2284 }
2285
2286 #[test]
2287 fn set_clip_applies_to_following_primitives() {
2288 let mut scene = Scene::new();
2289 let clip = Rect::new(0.0, 0.0, 10.0, 10.0);
2290 scene.set_clip(clip);
2291 scene.fill_rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::BLACK);
2292 if let Primitive::Rect { clip: c, .. } = scene.primitives()[0] {
2293 assert_eq!(c, clip);
2294 } else {
2295 panic!("expected a rect");
2296 }
2297 }
2298
2299 /// **A layer moved moves its mask** (milestone 610): a mask's geometry is held in the
2300 /// scene's coordinates once resolved from its box, so a layer drawn somewhere else
2301 /// carries it along. It was left behind, so a moved masked layer was masked where it
2302 /// had been.
2303 #[test]
2304 fn a_moved_layer_carries_its_mask() {
2305 let mask = crate::ShaderMask {
2306 shader: crate::MaskShader::Linear {
2307 from: Point::new(10.0, 0.0),
2308 to: Point::new(110.0, 0.0),
2309 from_color: Color::WHITE,
2310 to_color: Color::TRANSPARENT,
2311 },
2312 blend: crate::BlendMode::Modulate,
2313 };
2314 let layer = Primitive::Layer {
2315 primitives: Vec::new(),
2316 opacity: 1.0,
2317 clip: Rect::new(10.0, 0.0, 100.0, 20.0),
2318 clip_shape: ClipShape::Rect,
2319 transform: None,
2320 filter: crate::LayerFilter {
2321 mask: Some(mask),
2322 ..crate::LayerFilter::NONE
2323 },
2324 owner: 0,
2325 };
2326 let moved = layer.translated(-10.0, 5.0);
2327 let Primitive::Layer { filter, clip, .. } = moved else {
2328 unreachable!()
2329 };
2330 assert_eq!(clip, Rect::new(0.0, 5.0, 100.0, 20.0));
2331 let Some(crate::ShaderMask {
2332 shader: crate::MaskShader::Linear { from, to, .. },
2333 ..
2334 }) = filter.mask
2335 else {
2336 panic!("the mask is kept")
2337 };
2338 assert_eq!((from, to), (Point::new(0.0, 5.0), Point::new(100.0, 5.0)));
2339 }
2340
2341 /// A bitmap only moved and scaled is one image; a turned one is drawn upright at
2342 /// its size on screen and turned by its layer.
2343 #[test]
2344 fn an_image_is_drawn_through_its_map() {
2345 let image = crate::ImageData::from_rgba(4, 2, vec![255; 32]).into_handle();
2346 let mut scene = Scene::new();
2347 scene.set_clip(Rect::new(0.0, 0.0, 500.0, 500.0));
2348 let placed = Affine::scale(10.0, 5.0).then(Affine::translation(30.0, 40.0));
2349 scene.draw_image_mapped(&image, placed, Color::WHITE);
2350 let Primitive::Image { rect, uv, .. } = &scene.primitives()[0] else {
2351 panic!("{:?}", scene.primitives()[0])
2352 };
2353 assert_eq!(*rect, Rect::new(30.0, 40.0, 40.0, 10.0));
2354 assert_eq!(*uv, Rect::new(0.0, 0.0, 1.0, 1.0));
2355
2356 let mut scene = Scene::new();
2357 scene.set_clip(Rect::new(0.0, 0.0, 500.0, 500.0));
2358 let turned = Affine::scale(10.0, 10.0)
2359 .then(Affine::rotation(std::f32::consts::FRAC_PI_2))
2360 .then(Affine::translation(200.0, 100.0));
2361 scene.draw_image_mapped(&image, turned, Color::WHITE);
2362 let Primitive::Layer {
2363 primitives,
2364 transform: Some(transform),
2365 clip,
2366 ..
2367 } = &scene.primitives()[0]
2368 else {
2369 panic!("{:?}", scene.primitives()[0])
2370 };
2371 assert_eq!(*clip, Rect::new(0.0, 0.0, 500.0, 500.0), "the screen's");
2372 let Primitive::Image { rect, clip, .. } = &primitives[0] else {
2373 panic!()
2374 };
2375 assert_eq!(
2376 *rect,
2377 Rect::new(0.0, 0.0, 40.0, 20.0),
2378 "upright, on-screen size"
2379 );
2380 assert_eq!(*clip, *rect);
2381 // The flat image's far corner lands where the map sends the pixels' corner.
2382 let far = transform.affine.apply(Point::new(40.0, 20.0));
2383 let want = turned.apply(Point::new(4.0, 2.0));
2384 assert!(
2385 (far.x - want.x).abs() < 1e-3 && (far.y - want.y).abs() < 1e-3,
2386 "{far:?}"
2387 );
2388
2389 let mut scene = Scene::new();
2390 scene.draw_image_mapped(&image, Affine::scale(0.0, 1.0), Color::WHITE);
2391 assert!(scene.primitives().is_empty(), "flattened to nothing");
2392 }
2393}