frust-scene 0.6.2

Renderer-agnostic vector scene and display list builder used between Frust widgets and the GPU backend.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
//! The [`Scene`] display list and the [`Command`]s it holds.

use kurbo::{Affine, BezPath, Point, Rect};
use peniko::{Brush, Color, ImageData};

use crate::glyph::GlyphRun;
use crate::shader::ShaderProgram;

/// Per-corner radii for a rounded rectangle, in the pre-transform coordinate
/// space.
///
/// Corners are named clockwise from the top-left, matching
/// `kurbo::RoundedRectRadii` — the concrete shape `frust-render` builds at
/// encode time (scene-layer purity keeps the shape type itself out of this
/// crate's commands).
///
/// `From<f64>` covers the uniform case, so the one-radius callers that predate
/// this type keep passing a bare `f64` and the builder converts:
/// `CornerRadii::from(8.0)` == `CornerRadii::new(8.0, 8.0, 8.0, 8.0)`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CornerRadii {
    /// Top-left corner radius.
    pub top_left: f64,
    /// Top-right corner radius.
    pub top_right: f64,
    /// Bottom-right corner radius.
    pub bottom_right: f64,
    /// Bottom-left corner radius.
    pub bottom_left: f64,
}

impl CornerRadii {
    /// Radii for each corner, clockwise from the top-left (the
    /// `kurbo::RoundedRectRadii::new` argument order).
    pub const fn new(top_left: f64, top_right: f64, bottom_right: f64, bottom_left: f64) -> Self {
        Self {
            top_left,
            top_right,
            bottom_right,
            bottom_left,
        }
    }

    /// The same `radius` on all four corners — what [`From<f64>`] builds.
    pub const fn uniform(radius: f64) -> Self {
        Self::new(radius, radius, radius, radius)
    }

    /// The largest of the four corner radii.
    ///
    /// A backend that can only express a single radius (both blurred-shadow
    /// primitives take one) lowers through this rather than through the
    /// smallest: a shadow rounded *more* than its caster only lightens a square
    /// corner, while one rounded less pushes a hard shadow wedge out through a
    /// rounded corner's notch.
    pub fn largest(&self) -> f64 {
        self.top_left
            .max(self.top_right)
            .max(self.bottom_right)
            .max(self.bottom_left)
    }
}

impl From<f64> for CornerRadii {
    fn from(radius: f64) -> Self {
        Self::uniform(radius)
    }
}

/// A stroke's dash pattern: an `on` run, an `off` gap, and a `phase` offset
/// into that repeating cycle — all lengths in the pre-transform coordinate
/// space.
///
/// One on/off pair rather than an arbitrary-length array: it covers the dashed
/// dividers/outlines/focus rings widgets ask for, stays `Copy` (so
/// [`PathStyle`] and every command holding one stay `Copy`/allocation-free),
/// and the render crate expands it into a `[on, off]` slice at encode time.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DashPattern {
    /// Length of each painted dash.
    pub on: f64,
    /// Length of each gap between dashes.
    pub off: f64,
    /// Distance into the on/off cycle the pattern starts at — animate this to
    /// march the dashes along the path.
    pub phase: f64,
}

/// Minimum total dash period (on + off) in logical pixels for a pattern to be
/// rendered as dashed. Below this, the dash segments would be too small to see
/// and the period becomes too dense to efficiently expand at encode time, so
/// the pattern falls back to a solid stroke. This is the threshold below which
/// a dash pattern is visually indistinguishable from a solid stroke anyway.
const DASH_PERIOD_EPSILON: f64 = 0.1;

impl DashPattern {
    /// An `on`/`off` cycle starting at phase 0.
    pub const fn new(on: f64, off: f64) -> Self {
        Self {
            on,
            off,
            phase: 0.0,
        }
    }

    /// The same pattern offset by `phase` into its cycle.
    pub const fn with_phase(mut self, phase: f64) -> Self {
        self.phase = phase;
        self
    }

    /// Whether this pattern actually breaks a stroke into dashes.
    ///
    /// A non-positive or non-finite length has no dashed interpretation (a zero
    /// `off` is a solid line; a zero `on` paints nothing, which a caller never
    /// means by "dashed"), so `frust-render` strokes such a path solid rather
    /// than feeding a degenerate cycle to the dash iterator. Additionally, a
    /// period (on + off) smaller than [`DASH_PERIOD_EPSILON`] is too dense to
    /// efficiently expand at encode time and is visually indistinguishable from
    /// a solid stroke anyway, so the pattern falls back to solid.
    pub fn is_effective(&self) -> bool {
        self.on.is_finite()
            && self.off.is_finite()
            && self.phase.is_finite()
            && self.on > 0.0
            && self.off > 0.0
            && (self.on + self.off) >= DASH_PERIOD_EPSILON
    }
}

/// How a [`Command::Path`] is rendered: filled or stroked.
///
/// Kept intentionally minimal: the fill/stroke shape
/// widgets need for arcs (circular progress, activity indicators) — a
/// nonzero-fill, or a stroke with a fixed width, round caps/joins, and an
/// optional [`DashPattern`]. No miter limit or even-odd fill rule yet; extend
/// here (and in `frust-render::convert`) if a later widget needs one.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PathStyle {
    /// Fill using the nonzero winding rule.
    Fill,
    /// Stroke with the given width (pre-transform coordinate space) and
    /// round caps/joins.
    Stroke {
        /// Stroke width, in the pre-transform coordinate space.
        width: f64,
        /// Dash pattern, or `None` for a solid stroke — what every stroke that
        /// predates dashing records, and what the render crate falls back to
        /// for a degenerate pattern (see [`DashPattern::is_effective`]).
        dash: Option<DashPattern>,
    },
}

/// A single paint operation recorded into a [`Scene`].
///
/// This is the renderer-agnostic vocabulary the render crate (layer 4)
/// translates into backend draw calls. No `vello`/`wgpu` types appear here.
#[derive(Clone, Debug)]
pub enum Command {
    /// Fill an axis-aligned rectangle with a brush, under a transform.
    FillRect {
        rect: Rect,
        brush: Brush,
        transform: Affine,
    },
    /// Fill an axis-aligned rectangle with rounded corners.
    RoundedRect {
        rect: Rect,
        /// Per-corner radii, in the pre-transform coordinate space; a uniform
        /// radius arrives here as [`CornerRadii::uniform`].
        radii: CornerRadii,
        brush: Brush,
        transform: Affine,
    },
    /// Stroke a straight line segment from `p0` to `p1`.
    Line {
        p0: Point,
        p1: Point,
        /// Stroke width, in the pre-transform coordinate space.
        width: f64,
        brush: Brush,
        transform: Affine,
    },
    /// Draw a positioned run of glyphs.
    GlyphRun(GlyphRun),
    /// Push a rectangular clip onto the render backend's clip stack, under a
    /// transform. Subsequent draws are clipped to it until the matching
    /// [`Command::PopClip`].
    PushClip { rect: Rect, transform: Affine },
    /// Push a clip with rounded corners onto the render backend's clip stack,
    /// under a transform. Subsequent draws are clipped to the rounded shape
    /// until the matching [`Command::PopClip`].
    ///
    /// Popped by the *same* [`Command::PopClip`] a [`Command::PushClip`] uses —
    /// there is one clip stack, not a separate rounded one. The motivating
    /// consumer is a radiused mask over a bitmap (an avatar/thumbnail), which a
    /// rectangular clip cannot express.
    ///
    /// Carried as a `Rect` + [`CornerRadii`], mirroring
    /// [`Command::RoundedRect`] rather than naming a `kurbo::RoundedRect`; the
    /// render crate reconstitutes the concrete shape at encode time. A clip
    /// to an arbitrary outline is [`Command::PushClipPath`].
    PushClipRounded {
        rect: Rect,
        /// Per-corner radii, in the pre-transform coordinate space.
        radii: CornerRadii,
        transform: Affine,
    },
    /// Push a clip to an arbitrary closed outline onto the render backend's
    /// clip stack, under a transform. Subsequent draws are clipped to the
    /// path's interior (nonzero winding rule) until the matching
    /// [`Command::PopClip`].
    ///
    /// Popped by the *same* [`Command::PopClip`] every other clip uses — one
    /// clip stack. `path` is positioned in the same coordinate space as every
    /// other command (the caller has translated it to the widget's origin
    /// before recording), exactly as [`Command::Path`]'s is; open subpaths are
    /// closed implicitly, as a fill closes them.
    ///
    /// Lowered as a **coverage mask, no layer page**: the outline is
    /// rasterized once into coverage the draws inside it are generated
    /// against, the way a rounded clip is — never an offscreen layer. Because
    /// it is a mask, **a retained body under it is never retained**: a
    /// [`Command::PushRetained`] bracket opened inside a path clip always
    /// paints inline, as one inside a rounded clip does.
    PushClipPath { path: BezPath, transform: Affine },
    /// Pop the most recently pushed clip, whichever of [`Command::PushClip`],
    /// [`Command::PushClipRounded`] or [`Command::PushClipPath`] pushed it.
    PopClip,
    /// Draw a decoded image (natural pixel size `data.width`x`data.height`),
    /// scaled to fill `dest`, under a transform.
    ///
    /// `data` is cloned from the widget's cached `ImageSource` each frame;
    /// `peniko::ImageData`'s `Blob<u8>` is reference-counted internally, so
    /// this is a cheap handle clone, never a pixel copy or re-decode.
    Image {
        data: ImageData,
        dest: Rect,
        transform: Affine,
    },
    /// Draw a rounded rectangle with a gaussian-blurred elevation shadow (an
    /// approximation of a CSS `box-shadow`), under a transform.
    ///
    /// `radii` are the rectangle's corner radii, `std_dev` the blur's standard
    /// deviation, both in the pre-transform coordinate space. Maps onto vello
    /// 0.9's `Scene::draw_blurred_rounded_rect`, whose `brush` parameter is a
    /// concrete `peniko::Color` (not a `Brush`) — a blurred shadow has no
    /// gradient support in this vello version — and which takes a *single*
    /// radius, as does the CPU tier's `fill_blurred_rounded_rect`. A per-corner
    /// shadow therefore lowers through [`CornerRadii::largest`] at encode time
    /// (see `frust-render::convert`); the field carries all four corners so
    /// this command shares one radii vocabulary with its rounded siblings and
    /// gains per-corner blur for free if a backend ever grows it.
    BlurredRoundedRect {
        rect: Rect,
        radii: CornerRadii,
        std_dev: f64,
        color: Color,
        transform: Affine,
    },
    /// Push a translucent layer onto the render backend's layer stack, under
    /// a transform. Subsequent draws are composited at `alpha` until the
    /// matching [`Command::PopLayer`].
    ///
    /// Semantically a generalization of [`Command::PushClip`] (which is
    /// `PushLayer` with `alpha: 1.0`) — kept as a distinct variant rather than
    /// folded into it so existing `PushClip`/`PopClip` consumers are
    /// unaffected (see `frust-render::convert`).
    PushLayer {
        rect: Rect,
        alpha: f32,
        transform: Affine,
    },
    /// Pop the most recently pushed layer.
    PopLayer,
    /// Clear an axis-aligned rectangle to full transparency (alpha 0) under a
    /// transform, erasing everything already drawn beneath it in this scene —
    /// a real destination-clearing composite, not a skipped paint.
    ///
    /// The platform-view hole-punch is the sole v1 producer (see
    /// `frust-core`'s `PaintScene::clear_rect`): a translucent-surface (Mode B)
    /// slot punches its rect so an opaque app backdrop painted below it (the
    /// catalog's `AppBackground`) doesn't seal the hole the hosted native view
    /// shows through. `frust-render::convert` lowers this to a destination-out
    /// composite (an opaque fill erasing color and alpha wherever it covers),
    /// hoisted to the scene root past any enclosing clip/opacity group so a
    /// nested slot's punch isn't confined to its own group's content — the
    /// exact composite mode and hoist mechanics are `frust-render`'s to name
    /// (scene-layer purity: no vello types here). The clear only becomes
    /// visible on a surface that actually carries an alpha channel; on an
    /// opaque surface the transparency is disregarded (vello's surface
    /// contract), which is why the producer gates it on the translucent flag
    /// rather than punching always.
    ClearRect { rect: Rect, transform: Affine },
    /// Fill or stroke an arbitrary vector path (e.g. an arc), under a
    /// transform.
    ///
    /// `path` is a `kurbo::BezPath` already positioned in the same
    /// coordinate space as every other command (the caller has translated it
    /// to the widget's origin before recording); `style` selects fill vs.
    /// stroke (see [`PathStyle`]).
    Path {
        path: BezPath,
        style: PathStyle,
        brush: Brush,
        transform: Affine,
    },
    /// Draw a fragment-shader-filled rectangle, scaled to fill `dest`, under
    /// a transform.
    ///
    /// `program` is compiled (and cache-keyed on [`ShaderProgram::id`]) by
    /// `frust-engine`'s effects module. Output is treated as premultiplied
    /// alpha. `time` is seconds, app-supplied (from `PaintCtx::frame_time` at
    /// the widget layer), threaded into the shader's uniform buffer. This
    /// command lives at the scene layer to preserve purity: the render backend
    /// interprets the compiled shader output.
    ShaderQuad {
        program: ShaderProgram,
        dest: Rect,
        transform: Affine,
        time: f32,
    },
    /// Marks the start of a retained-layer bracket, under a transform.
    ///
    /// The commands between this and the matching [`Command::PopRetained`] are
    /// the BODY, recorded in the ordinary composed transform space (i.e. not
    /// pre-multiplied by `scale`). `rect` is the body's bounds in the local
    /// space of `transform` — the same convention [`Command::PushLayer`]'s
    /// `rect`/`alpha`/`transform` use.
    ///
    /// `alpha` (`0.0..=1.0`) and `scale` (uniform, about `rect`'s center) are
    /// PRESENTATION parameters applied to the body as a whole — deliberately
    /// NOT baked into the body's own commands, so a renderer can reuse a
    /// rasterized body while they animate.
    ///
    /// A renderer that retains MAY rasterize the body once under `key` and
    /// later honour a [`Command::DrawRetained`] with the same key from that
    /// page, drawing it with `transform * scale_about(rect.center(), scale)`
    /// inside an alpha layer. A renderer that does not MUST paint the body
    /// inline wrapped exactly as if the recorder had emitted
    /// `push_transform(scale_about(rect.center(), scale))` (when `scale !=
    /// 1.0`) then `push_layer(rect, alpha)` (when `alpha < 1.0`) — pops
    /// reversed (see `frust-render::convert`'s miss path).
    ///
    /// Syntactic nesting is allowed, but only the OUTERMOST bracket needs
    /// honouring — an inner bracket's own `alpha`/`scale` may be ignored by a
    /// non-implementing renderer. An unbalanced [`Command::PopRetained`] is
    /// ignored, the same policy [`Command::PopLayer`] follows.
    PushRetained {
        /// Key identifying this bracket's body across frames.
        key: u64,
        rect: Rect,
        alpha: f32,
        /// Uniform scale, applied about `rect`'s center.
        scale: f64,
        transform: Affine,
    },
    /// Pop the most recently pushed retained bracket (see
    /// [`Command::PushRetained`]).
    PopRetained,
    /// Draw a previously retained body from its cached page, with the same
    /// `key`, `rect`, `transform`, `alpha` and `scale` meanings as
    /// [`Command::PushRetained`].
    ///
    /// A `DrawRetained` only appears in a scene when the resident-key ledger
    /// said the key is resident, so a renderer that does not retain never sees
    /// one: it MUST treat it as a renderer error (the CPU oracle panics in
    /// tests; the engine logs once, skips the frame and clears its store).
    DrawRetained {
        key: u64,
        rect: Rect,
        transform: Affine,
        alpha: f32,
        scale: f64,
    },
    /// Draw an externally owned GPU texture scaled to fill `dest` under
    /// `transform`.
    ///
    /// `id` is opaque scene-layer data (precedent: [`ShaderProgram`]'s opaque
    /// id, `shader.rs`) — only the render backend resolves it against
    /// textures registered with the GPU context; an unregistered id draws
    /// nothing.
    SceneTexture {
        id: u64,
        dest: Rect,
        transform: Affine,
    },
    /// Blur whatever is already painted beneath `rect`, under a transform —
    /// a frosted-glass backdrop (CSS `backdrop-filter: blur(radius)`).
    ///
    /// Everything the scene recorded *before* this command, in painter order,
    /// is the backdrop: the pixels under `rect` are replaced by a Gaussian
    /// blur of standard deviation `radius` (in the pre-transform coordinate
    /// space, scaled into device space by `transform` the way a blurred
    /// rounded rect's `std_dev` is) of what was painted there, drawn through
    /// any clip open around the command — a rounded or path clip masks the
    /// blurred region exactly as it masks a fill. Commands recorded after
    /// this one paint over the blurred region as usual. The blur reads
    /// transparent black past the edge of the frame. A `radius` at or below
    /// zero blurs nothing and draws nothing.
    ///
    /// The backdrop is the *frame's own* target: a backdrop blur recorded
    /// inside an isolated (translucent) layer has no frame beneath it to
    /// sample and draws nothing, and a [`Command::PushRetained`] body that
    /// carries one is never retained — a page rasterized offscreen cannot
    /// sample what is painted beneath it. A renderer bounds how many it serves
    /// per frame (`frust-engine` refuses a frame recording more than four).
    BackdropBlur {
        rect: Rect,
        /// The blur's standard deviation, in the pre-transform coordinate
        /// space.
        radius: f64,
        transform: Affine,
    },
}

impl Command {
    /// Whether this command puts pixels on the target (a *drawing* command)
    /// rather than only opening or closing a group around other commands (a
    /// *structural* one).
    ///
    /// Drawing: [`Command::FillRect`], [`Command::RoundedRect`],
    /// [`Command::Line`], [`Command::GlyphRun`], [`Command::Image`],
    /// [`Command::BlurredRoundedRect`], [`Command::ClearRect`],
    /// [`Command::Path`], [`Command::ShaderQuad`], [`Command::DrawRetained`],
    /// [`Command::SceneTexture`] and [`Command::BackdropBlur`]. Structural:
    /// the clip pushes ([`Command::PushClip`], [`Command::PushClipRounded`],
    /// [`Command::PushClipPath`]) and [`Command::PopClip`], the layer bracket
    /// ([`Command::PushLayer`]/[`Command::PopLayer`]) and the retained bracket
    /// ([`Command::PushRetained`]/[`Command::PopRetained`]) — the same split
    /// the render backend's compile step applies implicitly (pushes open
    /// groups, pops close them, everything else draws).
    ///
    /// The platform-view differ uses it to decide whether a run of commands
    /// painted above a native slot carries any frust content worth an
    /// overlay. The match is exhaustive on purpose: a new variant must be
    /// classified here before it compiles.
    pub fn is_drawing(&self) -> bool {
        match self {
            Command::FillRect { .. }
            | Command::RoundedRect { .. }
            | Command::Line { .. }
            | Command::GlyphRun(_)
            | Command::Image { .. }
            | Command::BlurredRoundedRect { .. }
            | Command::ClearRect { .. }
            | Command::Path { .. }
            | Command::ShaderQuad { .. }
            | Command::DrawRetained { .. }
            | Command::SceneTexture { .. }
            | Command::BackdropBlur { .. } => true,
            Command::PushClip { .. }
            | Command::PushClipRounded { .. }
            | Command::PushClipPath { .. }
            | Command::PopClip
            | Command::PushLayer { .. }
            | Command::PopLayer
            | Command::PushRetained { .. }
            | Command::PopRetained => false,
        }
    }
}

/// Renderer-agnostic, immediate-mode display list.
///
/// Widgets paint into a `Scene` (via [`crate::SceneBuilder`]) each frame; the
/// render crate consumes [`Scene::commands`] to draw. Rebuilt per frame — call
/// [`Scene::reset`] before re-recording rather than allocating a new `Scene`.
#[derive(Clone, Debug, Default)]
pub struct Scene {
    commands: Vec<Command>,
    /// [`crate::SceneBuilder`]'s transform stack, parked here between frames
    /// so its backing `Vec` allocation is
    /// reused across every `SceneBuilder::new` call instead of reallocating
    /// per frame — a `SceneBuilder` borrows it via `&mut Scene` and resets it
    /// to `[Affine::IDENTITY]` on construction, so behavior is unchanged.
    /// Not part of the stable widget-facing API.
    pub(crate) transform_stack: Vec<Affine>,
    /// Depth counter for open [`Command::PushRetained`] brackets, incremented
    /// by [`crate::SceneBuilder::push_retained`] and decremented — only when
    /// greater than zero — by [`crate::SceneBuilder::pop_retained`], the same
    /// unbalanced-pop policy [`Command::PopClip`]/[`Command::PopLayer`]
    /// follow. Reset alongside `commands` in [`Scene::reset`]; NOT reset by
    /// [`crate::SceneBuilder::new`] (unlike `transform_stack`), since it
    /// tracks bracket balance across the whole scene, not a builder session.
    /// Not part of the stable widget-facing API.
    pub(crate) retained_depth: usize,
}

impl Scene {
    /// Creates an empty scene.
    pub fn new() -> Self {
        Self::default()
    }

    /// Clears all recorded commands so the scene can be reused for the next frame.
    pub fn reset(&mut self) {
        self.commands.clear();
        self.retained_depth = 0;
    }

    /// The recorded commands for this frame, in paint order.
    pub fn commands(&self) -> &[Command] {
        &self.commands
    }

    /// Records a command. Used by [`crate::SceneBuilder`]; not part of the
    /// stable widget-facing API.
    pub(crate) fn push(&mut self, command: Command) {
        self.commands.push(command);
    }

    /// How many [`Command::PushRetained`] brackets are still open.
    ///
    /// `#[doc(hidden)]`, and deliberately not part of the stable widget-facing
    /// API: a widget has no business reading the bracket depth mid-recording,
    /// and nothing in the framework branches on it. It exists so a property
    /// test outside this crate can assert the balance contract the field's own
    /// docs state — that a balanced sequence leaves the depth at zero and an
    /// unmatched [`crate::SceneBuilder::pop_retained`] never drives it below
    /// zero — which is otherwise unobservable from the command stream alone,
    /// since an ignored pop records nothing to observe.
    #[doc(hidden)]
    pub fn retained_depth(&self) -> usize {
        self.retained_depth
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn new_scene_has_no_commands() {
        let scene = Scene::new();
        assert!(scene.commands().is_empty());
    }

    #[test]
    fn reset_clears_commands() {
        let mut scene = Scene::new();
        scene.push(Command::PushClip {
            rect: Rect::new(0.0, 0.0, 1.0, 1.0),
            transform: Affine::IDENTITY,
        });
        assert_eq!(scene.commands().len(), 1);

        scene.reset();
        assert!(scene.commands().is_empty());
    }

    #[test]
    fn reset_clears_retained_depth() {
        let mut scene = Scene::new();
        scene.retained_depth = 2;
        scene.reset();
        assert_eq!(scene.retained_depth, 0);
    }

    #[test]
    fn is_drawing_classifies_every_variant() {
        use crate::glyph::FontHandle;
        use peniko::FontData;

        let rect = Rect::new(0.0, 0.0, 4.0, 4.0);
        let t = Affine::IDENTITY;
        let brush = Brush::Solid(Color::from_rgba8(1, 2, 3, 255));
        let mut path = BezPath::new();
        path.move_to((0.0, 0.0));
        path.line_to((4.0, 4.0));
        let image = ImageData {
            data: peniko::Blob::from(vec![0u8; 4]),
            format: peniko::ImageFormat::Rgba8,
            alpha_type: peniko::ImageAlphaType::Alpha,
            width: 1,
            height: 1,
        };
        let glyphs = GlyphRun {
            font: FontHandle::new(FontData::new(peniko::Blob::from(Vec::<u8>::new()), 0)),
            font_size: 12.0,
            brush: brush.clone(),
            transform: t,
            glyphs: Vec::new(),
        };

        let drawing = [
            Command::FillRect {
                rect,
                brush: brush.clone(),
                transform: t,
            },
            Command::RoundedRect {
                rect,
                radii: CornerRadii::uniform(2.0),
                brush: brush.clone(),
                transform: t,
            },
            Command::Line {
                p0: Point::ORIGIN,
                p1: Point::new(4.0, 4.0),
                width: 1.0,
                brush: brush.clone(),
                transform: t,
            },
            Command::GlyphRun(glyphs),
            Command::Image {
                data: image,
                dest: rect,
                transform: t,
            },
            Command::BlurredRoundedRect {
                rect,
                radii: CornerRadii::uniform(2.0),
                std_dev: 3.0,
                color: Color::from_rgba8(0, 0, 0, 255),
                transform: t,
            },
            Command::ClearRect { rect, transform: t },
            Command::Path {
                path: path.clone(),
                style: PathStyle::Fill,
                brush: brush.clone(),
                transform: t,
            },
            Command::ShaderQuad {
                program: ShaderProgram::new("// test"),
                dest: rect,
                transform: t,
                time: 0.0,
            },
            Command::DrawRetained {
                key: 1,
                rect,
                transform: t,
                alpha: 1.0,
                scale: 1.0,
            },
            Command::SceneTexture {
                id: 7,
                dest: rect,
                transform: t,
            },
            Command::BackdropBlur {
                rect,
                radius: 4.0,
                transform: t,
            },
        ];
        let structural = [
            Command::PushClip { rect, transform: t },
            Command::PushClipRounded {
                rect,
                radii: CornerRadii::uniform(2.0),
                transform: t,
            },
            Command::PushClipPath { path, transform: t },
            Command::PopClip,
            Command::PushLayer {
                rect,
                alpha: 0.5,
                transform: t,
            },
            Command::PopLayer,
            Command::PushRetained {
                key: 1,
                rect,
                alpha: 1.0,
                scale: 1.0,
                transform: t,
            },
            Command::PopRetained,
        ];

        // 12 drawing + 8 structural = all 20 variants.
        assert_eq!(drawing.len() + structural.len(), 20);
        for command in &drawing {
            assert!(command.is_drawing(), "{command:?} should be drawing");
        }
        for command in &structural {
            assert!(!command.is_drawing(), "{command:?} should be structural");
        }
    }

    #[test]
    fn corner_radii_from_f64_is_uniform() {
        assert_eq!(CornerRadii::from(6.0), CornerRadii::new(6.0, 6.0, 6.0, 6.0));
        assert_eq!(CornerRadii::from(6.0), CornerRadii::uniform(6.0));
    }

    #[test]
    fn corner_radii_new_orders_corners_clockwise_from_top_left() {
        // The argument order is load-bearing: it must match
        // `kurbo::RoundedRectRadii::new`, which `frust-render` converts into.
        let radii = CornerRadii::new(1.0, 2.0, 3.0, 4.0);
        assert_eq!(radii.top_left, 1.0);
        assert_eq!(radii.top_right, 2.0);
        assert_eq!(radii.bottom_right, 3.0);
        assert_eq!(radii.bottom_left, 4.0);
    }

    #[test]
    fn corner_radii_largest_picks_the_biggest_corner() {
        assert_eq!(CornerRadii::new(1.0, 9.0, 3.0, 4.0).largest(), 9.0);
        assert_eq!(CornerRadii::uniform(2.0).largest(), 2.0);
        assert_eq!(CornerRadii::default().largest(), 0.0);
    }

    #[test]
    fn dash_pattern_defaults_to_zero_phase_and_carries_with_phase() {
        let dash = DashPattern::new(4.0, 2.0);
        assert_eq!(dash.phase, 0.0);
        assert_eq!(dash.with_phase(1.5).phase, 1.5);
        // `with_phase` leaves the cycle itself alone.
        assert_eq!(dash.with_phase(1.5).on, 4.0);
        assert_eq!(dash.with_phase(1.5).off, 2.0);
    }

    #[test]
    fn degenerate_dash_patterns_are_not_effective() {
        assert!(DashPattern::new(4.0, 2.0).is_effective());
        assert!(!DashPattern::new(0.0, 2.0).is_effective());
        assert!(!DashPattern::new(4.0, 0.0).is_effective());
        assert!(!DashPattern::new(-4.0, 2.0).is_effective());
        assert!(!DashPattern::new(f64::NAN, 2.0).is_effective());
        assert!(!DashPattern::new(f64::INFINITY, 2.0).is_effective());
        assert!(
            !DashPattern::new(4.0, 2.0)
                .with_phase(f64::NAN)
                .is_effective()
        );
    }

    #[test]
    fn tiny_period_dash_patterns_are_not_effective() {
        // A pattern with a vanishingly small period (on + off) below the
        // epsilon is not effective, falling back to solid stroke. This prevents
        // the dash iterator from expanding into astronomically many segments.
        assert!(!DashPattern::new(1e-9, 1e-9).is_effective());
        assert!(!DashPattern::new(0.01, 0.01).is_effective()); // period 0.02 < 0.1
        assert!(!DashPattern::new(0.04, 0.05).is_effective()); // period 0.09 < 0.1
    }

    #[test]
    fn dash_pattern_at_epsilon_boundary_is_effective() {
        // At the epsilon boundary, the pattern is exactly effective (not strict <).
        assert!(DashPattern::new(0.05, 0.05).is_effective()); // period exactly 0.1
        assert!(DashPattern::new(0.03, 0.07).is_effective()); // period exactly 0.1
    }

    #[test]
    fn normal_dash_patterns_remain_effective() {
        // Normal-sized patterns well above epsilon remain effective.
        assert!(DashPattern::new(1.0, 0.5).is_effective());
        assert!(DashPattern::new(4.0, 2.0).is_effective());
        assert!(DashPattern::new(10.0, 10.0).is_effective());
    }
}