Skip to main content

cranpose_render_common/
style_shared.rs

1use std::rc::Rc;
2
3use cranpose_foundation::PointerEvent;
4use cranpose_ui::{Brush, DrawCommand, DrawCommandFn, LayoutNodeData, ModifierNodeSlices};
5use cranpose_ui_graphics::{
6    BlendMode, Color, ColorFilter, CommandRecording, CompositingStrategy, CornerRadii,
7    DrawPrimitive, FinishedRecording, GraphicsLayer, Point, RoundedCornerShape, Size,
8};
9
10use crate::layer_transform::{layer_scale_x, layer_scale_y, layer_uniform_scale};
11
12pub struct NodeStyle {
13    pub padding: cranpose_ui_graphics::EdgeInsets,
14    pub background: Option<Color>,
15    pub click_actions: Vec<Rc<dyn Fn(Point)>>,
16    pub shape: Option<RoundedCornerShape>,
17    pub pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
18    pub draw_commands: Vec<DrawCommand>,
19    pub graphics_layer: Option<GraphicsLayer>,
20    pub clip_to_bounds: bool,
21}
22
23impl NodeStyle {
24    pub fn from_layout_node(data: &LayoutNodeData) -> Self {
25        let resolved = data.resolved_modifiers;
26        let slices: &ModifierNodeSlices = data.modifier_slices();
27        let pointer_inputs = slices.pointer_inputs().to_vec();
28
29        Self {
30            padding: resolved.padding(),
31            background: None,
32            click_actions: slices.click_handlers().to_vec(),
33            shape: None,
34            pointer_inputs,
35            draw_commands: slices.draw_commands().to_vec(),
36            graphics_layer: slices.graphics_layer(),
37            clip_to_bounds: slices.clip_to_bounds(),
38        }
39    }
40}
41
42pub fn combine_layers(
43    current: GraphicsLayer,
44    modifier_layer: Option<GraphicsLayer>,
45) -> GraphicsLayer {
46    if let Some(layer) = modifier_layer {
47        GraphicsLayer {
48            alpha: (current.alpha * layer.alpha).clamp(0.0, 1.0),
49            scale: current.scale * layer.scale,
50            scale_x: current.scale_x * layer.scale_x,
51            scale_y: current.scale_y * layer.scale_y,
52            rotation_x: current.rotation_x + layer.rotation_x,
53            rotation_y: current.rotation_y + layer.rotation_y,
54            rotation_z: current.rotation_z + layer.rotation_z,
55            camera_distance: layer.camera_distance,
56            transform_origin: layer.transform_origin,
57            translation_x: current.translation_x + layer.translation_x,
58            translation_y: current.translation_y + layer.translation_y,
59            shadow_elevation: layer.shadow_elevation,
60            ambient_shadow_color: layer.ambient_shadow_color,
61            spot_shadow_color: layer.spot_shadow_color,
62            shape: layer.shape,
63            clip: current.clip || layer.clip,
64            color_filter: compose_color_filters(current.color_filter, layer.color_filter),
65            compositing_strategy: layer.compositing_strategy,
66            blend_mode: layer.blend_mode,
67            // render_effect is NOT inherited — it applies only to this layer's subtree
68            render_effect: layer.render_effect,
69            // backdrop_effect is NOT inherited — it applies only to this node's backdrop.
70            backdrop_effect: layer.backdrop_effect,
71        }
72    } else {
73        GraphicsLayer {
74            compositing_strategy: CompositingStrategy::Auto,
75            blend_mode: BlendMode::SrcOver,
76            render_effect: None,
77            backdrop_effect: None,
78            ..current
79        }
80    }
81}
82
83pub use crate::graph::quad_bounds;
84
85/// The colour a primitive is actually painted in, once this layer has had its
86/// say.
87///
88/// The colour is snapped to eight bits *first*, because that is where the
89/// platform's own colour type already is by the time anything paints with it
90/// (see [`Color::srgb_8bit`]). Only then does the layer's alpha multiply it.
91/// The order is the whole point: an isolated layer's contents land in an 8-bit
92/// buffer and the alpha multiplies whole channel values, so
93/// `round(round(c * 255) * a)` and not `round(c * 255 * a)`.
94pub fn apply_layer_to_color(color: Color, layer: &GraphicsLayer) -> Color {
95    let color = color.srgb_8bit();
96    apply_color_filter_to_color(
97        Color(
98            color.0,
99            color.1,
100            color.2,
101            (color.3 * layer.alpha).clamp(0.0, 1.0),
102        ),
103        layer.color_filter,
104    )
105}
106
107fn apply_color_filter_to_color(color: Color, filter: Option<ColorFilter>) -> Color {
108    match filter {
109        Some(filter) => {
110            let [r, g, b, a] = filter.apply_rgba([color.0, color.1, color.2, color.3]);
111            Color(r, g, b, a)
112        }
113        None => color,
114    }
115}
116
117pub fn compose_color_filters(
118    base: Option<ColorFilter>,
119    overlay: Option<ColorFilter>,
120) -> Option<ColorFilter> {
121    match (base, overlay) {
122        (None, None) => None,
123        (Some(filter), None) | (None, Some(filter)) => Some(filter),
124        (Some(filter), Some(next)) => Some(filter.compose(next)),
125    }
126}
127
128pub fn apply_layer_to_brush(brush: Brush, layer: &GraphicsLayer) -> Brush {
129    // The overwhelmingly common case — full-alpha layer, no filter, unit
130    // scale — leaves every brush's geometry untouched; a scene of thousands of
131    // shape draws per frame should not rebuild it to discover that. The
132    // colours are still snapped, because a colour reaches the framebuffer at
133    // eight bits whether or not a layer touched it on the way.
134    if layer.alpha == 1.0
135        && layer.color_filter.is_none()
136        && layer_scale_x(layer) == 1.0
137        && layer_scale_y(layer) == 1.0
138    {
139        return map_brush_colors(brush, Color::srgb_8bit);
140    }
141    map_brush_colors(scale_brush_geometry(brush, layer), |color| {
142        apply_layer_to_color(color, layer)
143    })
144}
145
146/// Every colour a brush carries through `paint`, leaving its geometry alone.
147fn map_brush_colors(brush: Brush, paint: impl Fn(Color) -> Color) -> Brush {
148    match brush {
149        Brush::Solid(color) => Brush::solid(paint(color)),
150        Brush::LinearGradient {
151            colors,
152            stops,
153            start,
154            end,
155            tile_mode,
156        } => Brush::LinearGradient {
157            colors: colors.into_iter().map(paint).collect(),
158            stops,
159            start,
160            end,
161            tile_mode,
162        },
163        Brush::RadialGradient {
164            colors,
165            stops,
166            center,
167            radius,
168            tile_mode,
169        } => Brush::RadialGradient {
170            colors: colors.into_iter().map(paint).collect(),
171            stops,
172            center,
173            radius,
174            tile_mode,
175        },
176        Brush::SweepGradient {
177            colors,
178            stops,
179            center,
180        } => Brush::SweepGradient {
181            colors: colors.into_iter().map(paint).collect(),
182            stops,
183            center,
184        },
185    }
186}
187
188/// A brush's geometry under this layer's scale, leaving its colours alone.
189fn scale_brush_geometry(brush: Brush, layer: &GraphicsLayer) -> Brush {
190    let scale_x = layer_scale_x(layer);
191    let scale_y = layer_scale_y(layer);
192    let uniform_scale = layer_uniform_scale(layer);
193
194    match brush {
195        Brush::Solid(color) => Brush::Solid(color),
196        Brush::LinearGradient {
197            colors,
198            stops,
199            mut start,
200            mut end,
201            tile_mode,
202        } => {
203            start.x *= scale_x;
204            start.y *= scale_y;
205            end.x *= scale_x;
206            end.y *= scale_y;
207            Brush::LinearGradient {
208                colors,
209                stops,
210                start,
211                end,
212                tile_mode,
213            }
214        }
215        Brush::RadialGradient {
216            colors,
217            stops,
218            mut center,
219            mut radius,
220            tile_mode,
221        } => {
222            center.x *= scale_x;
223            center.y *= scale_y;
224            radius *= uniform_scale;
225            Brush::RadialGradient {
226                colors,
227                stops,
228                center,
229                radius,
230                tile_mode,
231            }
232        }
233        Brush::SweepGradient {
234            colors,
235            stops,
236            mut center,
237        } => {
238            center.x *= scale_x;
239            center.y *= scale_y;
240            Brush::SweepGradient {
241                colors,
242                stops,
243                center,
244            }
245        }
246    }
247}
248
249/// A layer-resolved brush, split at the solid/gradient boundary.
250///
251/// The per-frame shape emit produces one of these for every draw: the solid
252/// case — effectively all of a heavy animated scene — carries its color
253/// inline, so emitting a shape neither clones a `Brush` nor leaves an enum
254/// with heap-carrying variants for frame teardown to walk. Only the rare
255/// gradient still travels as a cloned [`Brush`].
256#[derive(Clone, Debug, PartialEq)]
257pub enum ResolvedBrush {
258    Solid(Color),
259    /// A non-solid brush (gradients), already layer-resolved.
260    Other(Brush),
261}
262
263impl ResolvedBrush {
264    pub fn from_brush(brush: Brush) -> Self {
265        match brush {
266            Brush::Solid(color) => Self::Solid(color),
267            other => Self::Other(other),
268        }
269    }
270
271    /// The plain `Brush` this resolved form stands for — same values,
272    /// reassembled for consumers that keep speaking `Brush`.
273    pub fn into_brush(self) -> Brush {
274        match self {
275            Self::Solid(color) => Brush::Solid(color),
276            Self::Other(brush) => brush,
277        }
278    }
279}
280
281/// [`apply_layer_to_brush`] without the solid-brush clone: the borrowed
282/// brush's color is copied (or layer-adjusted) inline, and only gradients
283/// are cloned. Produces exactly the values `apply_layer_to_brush` would —
284/// both branches below mirror its fast path and its `Solid` arm verbatim.
285pub fn resolve_layer_brush(brush: &Brush, layer: &GraphicsLayer) -> ResolvedBrush {
286    match brush {
287        Brush::Solid(color) => {
288            if layer.alpha == 1.0
289                && layer.color_filter.is_none()
290                && layer_scale_x(layer) == 1.0
291                && layer_scale_y(layer) == 1.0
292            {
293                ResolvedBrush::Solid(*color)
294            } else {
295                ResolvedBrush::Solid(apply_layer_to_color(*color, layer))
296            }
297        }
298        other => ResolvedBrush::Other(apply_layer_to_brush(other.clone(), layer)),
299    }
300}
301
302pub fn scale_corner_radii(radii: CornerRadii, scale: f32) -> CornerRadii {
303    CornerRadii {
304        top_left: radii.top_left * scale,
305        top_right: radii.top_right * scale,
306        bottom_right: radii.bottom_right * scale,
307        bottom_left: radii.bottom_left * scale,
308    }
309}
310
311#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
312pub enum DrawPlacement {
313    Behind,
314    Overlay,
315}
316
317pub fn primitives_for_placement(
318    command: &DrawCommand,
319    placement: DrawPlacement,
320    size: Size,
321) -> Vec<DrawPrimitive> {
322    primitives_for_placement_reusing(command, placement, size, Vec::new())
323}
324
325/// [`primitives_for_placement`] recording into `storage` the caller retains
326/// between frames. A command that re-records every frame keeps one buffer
327/// whose capacity was earned on earlier frames; only the rare marker-bearing
328/// recording pays for a second output vector.
329pub fn primitives_for_placement_reusing(
330    command: &DrawCommand,
331    placement: DrawPlacement,
332    size: Size,
333    storage: Vec<DrawPrimitive>,
334) -> Vec<DrawPrimitive> {
335    primitives_for_placement_retained(
336        command,
337        placement,
338        size,
339        CommandRecording::default(),
340        storage,
341    )
342    .0
343}
344
345/// The full retained form: the compact recording buffers come from and
346/// return to the caller alongside the materialized primitives, so a command
347/// re-recording every frame allocates nothing in the steady state.
348pub fn primitives_for_placement_retained(
349    command: &DrawCommand,
350    placement: DrawPlacement,
351    size: Size,
352    recording: CommandRecording,
353    storage: Vec<DrawPrimitive>,
354) -> (Vec<DrawPrimitive>, CommandRecording) {
355    let mut no_replay = None;
356    let (primitives, recording, _) = primitives_for_placement_verified(
357        command,
358        placement,
359        size,
360        recording,
361        storage,
362        &mut no_replay,
363        None,
364    );
365    (primitives, recording)
366}
367
368/// The replay half of [`primitives_for_placement_verified`]'s contract: the
369/// command's verification state plus the stale-transition seam. The caller
370/// (the scene builder, which owns the per-command registry) says whether a
371/// valid previous-frame emission is on hand; verification answers whether
372/// this frame collapsed out of its capture and was therefore NOT emitted —
373/// nothing materialized, the caller must re-emit its saved emission.
374pub struct CommandReplayContext<'a> {
375    pub state: &'a mut cranpose_ui_graphics::CommandReplayState,
376    /// In: the previous build emitted a replay frame the caller saved,
377    /// still valid under the current recording generation and retained
378    /// feed epoch, and the stale-transition flag is on.
379    pub stale_available: bool,
380    /// Out: verification collapsed out of an established capture while
381    /// `stale_available` held, so the recording was finished via
382    /// [`cranpose_ui_graphics::DrawScopeDefault::finish_recording_only`]
383    /// — no primitives, no frame — and the caller re-emits its saved
384    /// emission in place of this frame's.
385    pub serve_stale: bool,
386}
387
388/// [`primitives_for_placement_retained`] with per-command similarity
389/// verification: when `replay` carries the command's state, the freshly
390/// recorded compact form advances it BEFORE materialization, yielding the
391/// frame's retained/dynamic span structure in primitive space. Rendering
392/// still materializes everything — consuming the spans (and skipping
393/// materialization for retained ones) is the renderer-side half of the
394/// retention work. The frame is only returned for marker-free recordings:
395/// content splitting reindexes the primitive vector, and no game-scale
396/// command records content markers.
397pub fn primitives_for_placement_verified(
398    command: &DrawCommand,
399    placement: DrawPlacement,
400    size: Size,
401    recording: CommandRecording,
402    storage: Vec<DrawPrimitive>,
403    replay: &mut Option<CommandReplayContext<'_>>,
404    command_id: Option<crate::graph::DrawCommandId>,
405) -> (
406    Vec<DrawPrimitive>,
407    CommandRecording,
408    Option<cranpose_ui_graphics::CommandReplayFrame>,
409) {
410    // `markers` is the recording scope's own count of `Content` markers,
411    // maintained while the command records (`draw_content()` calls and pushed
412    // batches both keep it current), so it is authoritative: zero means the
413    // vector holds no marker and passes through untouched. On a watch-class
414    // core, re-streaming a fresh multi-thousand-primitive recording just to
415    // learn "no markers" is measurable frame time.
416    let filter_content = |primitives: Vec<DrawPrimitive>, markers: u32| {
417        if markers == 0 {
418            return primitives;
419        }
420        // `filter(...).collect()` reports a zero lower-bound size hint, so it
421        // grows the output through the whole doubling schedule; for an
422        // animated scene these vectors hold thousands of primitives and are
423        // rebuilt every frame. `Content` markers are rare, so the input
424        // length is the right capacity.
425        let mut out = Vec::with_capacity(primitives.len());
426        out.extend(
427            primitives
428                .into_iter()
429                .filter(|primitive| !matches!(primitive, DrawPrimitive::Content)),
430        );
431        out
432    };
433
434    let split_with_content = |primitives: Vec<DrawPrimitive>, placement, markers: u32| {
435        let last_content_idx = if markers == 0 {
436            None
437        } else {
438            primitives
439                .iter()
440                .rposition(|primitive| matches!(primitive, DrawPrimitive::Content))
441        };
442        let Some(last_content_idx) = last_content_idx else {
443            return if matches!(placement, DrawPlacement::Overlay) {
444                filter_content(primitives, markers)
445            } else {
446                Vec::new()
447            };
448        };
449
450        let mut out = Vec::with_capacity(primitives.len());
451        out.extend(
452            primitives
453                .into_iter()
454                .enumerate()
455                .filter_map(|(index, primitive)| {
456                    if matches!(primitive, DrawPrimitive::Content) {
457                        return None;
458                    }
459                    let is_before = index < last_content_idx;
460                    match placement {
461                        DrawPlacement::Behind if is_before => Some(primitive),
462                        DrawPlacement::Overlay if !is_before => Some(primitive),
463                        _ => None,
464                    }
465                }),
466        );
467        out
468    };
469
470    // The command records into a scope this consumer owns, so the marker
471    // count travels with the recording instead of through a side channel.
472    fn record_into(
473        func: &DrawCommandFn,
474        size: Size,
475        recording: CommandRecording,
476        storage: Vec<DrawPrimitive>,
477        replay: &mut Option<CommandReplayContext<'_>>,
478        command: Option<crate::graph::DrawCommandId>,
479    ) -> (
480        FinishedRecording,
481        Option<cranpose_ui_graphics::CommandReplayFrame>,
482    ) {
483        let mut scope = cranpose_ui::command_draw_scope_retained(size, recording, storage);
484        func(&mut scope);
485        let Some(ctx) = replay.as_mut() else {
486            return (scope.finish(), None);
487        };
488        let state = &mut *ctx.state;
489        let outcome =
490            state.advance_pooled(scope.recorded(), crate::scene_builder::verify_executor());
491        // Span counts are taken before `finish_replay` consumes the outcome
492        // (recolor patch lists move into the frame instead of being cloned
493        // every frame).
494        let diag = if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
495            if let cranpose_ui_graphics::ReplayOutcome::Spans(spans) = &outcome {
496                let (mut retained, mut dynamic) = (0usize, 0usize);
497                for span in spans {
498                    match span {
499                        cranpose_ui_graphics::ReplaySpan::Retained { .. } => retained += 1,
500                        cranpose_ui_graphics::ReplaySpan::Dynamic {
501                            tape_start,
502                            tape_end,
503                        } => dynamic += tape_end - tape_start,
504                    }
505                }
506                Some((
507                    scope.recorded().len(),
508                    retained,
509                    dynamic,
510                    state.segments().len(),
511                    state.stats(),
512                    state.optimistic_commits(),
513                    state.prefix_commits(),
514                ))
515            } else {
516                None
517            }
518        } else {
519            None
520        };
521        // Rate-limited engagement line, always on: the watch cannot take
522        // flag-gated diagnostics (its logcat is the only channel), and
523        // whether the pooled and prefix-commit fast paths engage on-device
524        // must be provable from a plain measurement window. warn level:
525        // the platform loggers filter info on desktop.
526        if !state.segments().is_empty() {
527            thread_local! {
528                static VERIFIED_FRAMES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
529            }
530            let frames = VERIFIED_FRAMES.with(|cell| {
531                let next = cell.get().wrapping_add(1);
532                cell.set(next);
533                next
534            });
535            if frames.is_multiple_of(256) {
536                let (deaths, splits) = state.stats();
537                log::warn!(
538                    "[command-replay] health: {} segments, pooled commits {} + prefix {}, \
539                     lifetime deaths {} splits {}",
540                    state.segments().len(),
541                    state.optimistic_commits(),
542                    state.prefix_commits(),
543                    deaths,
544                    splits,
545                );
546            }
547        }
548        let center = state.center();
549        // Only spans whose retained buffer the renderer has confirmed may
550        // skip materialization: everything else must exist as primitives
551        // for this frame's ordinary path. A marker-bearing recording drops
552        // its frame after the split, so it must materialize whole.
553        let markers = scope.content_marker_count();
554        if ctx.stale_available && markers == 0 && state.collapsed_from_captured() {
555            // The transition frame: verification collapsed out of an
556            // established capture, so emitting `outcome` would materialize
557            // and re-encode the ENTIRE tape in one frame — 22-75 ms on a
558            // watch-class core against a ~17k-record scene, two to four
559            // missed vsyncs. The caller holds the previous frame's
560            // emission; serve that instead. Nothing materializes here, and
561            // the advance above already re-snapshotted, so re-convergence
562            // proceeds on the next frames exactly as if this frame had
563            // emitted its collapse. A marker-bearing recording never
564            // qualifies: its frame would have been dropped anyway, so
565            // there is no saved emission shape that matches it.
566            ctx.serve_stale = true;
567            if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
568                log::warn!(
569                    "[command-replay] stale transition: collapse frame of {} records \
570                     re-serves the previous emission",
571                    scope.recorded().len(),
572                );
573            }
574            return (scope.finish_recording_only(), None);
575        }
576        let mut bypass = |slot: u32| {
577            markers == 0
578                && command.is_some_and(|id| crate::scene_builder::retained_slot_confirmed(id, slot))
579        };
580        let (finished, frame) = scope.finish_replay(center, outcome, &mut bypass);
581        if let Some((records, retained, dynamic, segments, (deaths, splits), pooled, prefix)) = diag
582        {
583            log::warn!(
584                "[command-replay] {} records: {} retained spans, {} dynamic records, \
585                 {} materialized; {} segments alive, lifetime deaths {} splits {}, \
586                 pooled commits {} + prefix {}",
587                records,
588                retained,
589                dynamic,
590                finished.primitives.len(),
591                segments,
592                deaths,
593                splits,
594                pooled,
595                prefix,
596            );
597        }
598        (finished, frame)
599    }
600    match (placement, command) {
601        (DrawPlacement::Behind, DrawCommand::Behind(func)) => {
602            let (finished, frame) = record_into(func, size, recording, storage, replay, command_id);
603            let frame = (finished.content_markers == 0).then_some(frame).flatten();
604            (
605                filter_content(finished.primitives, finished.content_markers),
606                finished.recording,
607                frame,
608            )
609        }
610        (DrawPlacement::Overlay, DrawCommand::Overlay(func)) => {
611            let (finished, frame) = record_into(func, size, recording, storage, replay, command_id);
612            let frame = (finished.content_markers == 0).then_some(frame).flatten();
613            (
614                filter_content(finished.primitives, finished.content_markers),
615                finished.recording,
616                frame,
617            )
618        }
619        (_, DrawCommand::WithContent(func)) => {
620            // Content splitting reindexes the vector; the frame's ranges
621            // would not survive it. No id means no bypass either.
622            let (finished, _) = record_into(func, size, recording, storage, replay, None);
623            (
624                split_with_content(finished.primitives, placement, finished.content_markers),
625                finished.recording,
626                None,
627            )
628        }
629        _ => (Vec::new(), recording, None),
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use cranpose_ui_graphics::{DrawScope, DrawScopeDefault, Rect};
637
638    fn recorded_command(record: impl Fn(&mut dyn DrawScope) + 'static) -> DrawCommandFn {
639        Rc::new(move |scope: &mut DrawScopeDefault| record(scope))
640    }
641
642    fn rect_at(x: f32) -> Rect {
643        Rect {
644            x,
645            y: 0.0,
646            width: 1.0,
647            height: 1.0,
648        }
649    }
650
651    fn rect_xs(primitives: &[DrawPrimitive]) -> Vec<f32> {
652        primitives
653            .iter()
654            .map(|primitive| match primitive {
655                DrawPrimitive::Rect { rect, .. } => rect.x,
656                other => panic!("unexpected primitive {other:?}"),
657            })
658            .collect()
659    }
660
661    #[test]
662    fn marker_free_recording_passes_through() {
663        let command = DrawCommand::Behind(recorded_command(|scope| {
664            scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
665            scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
666        }));
667        let out = primitives_for_placement(&command, DrawPlacement::Behind, Size::new(10.0, 10.0));
668        assert_eq!(rect_xs(&out), [1.0, 2.0]);
669    }
670
671    #[test]
672    fn recorded_markers_still_split_content_placements() {
673        let with_content = recorded_command(|scope| {
674            scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
675            scope.draw_content();
676            scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
677        });
678        let command = DrawCommand::WithContent(with_content);
679        let size = Size::new(10.0, 10.0);
680        let behind = primitives_for_placement(&command, DrawPlacement::Behind, size);
681        assert_eq!(rect_xs(&behind), [1.0]);
682        let overlay = primitives_for_placement(&command, DrawPlacement::Overlay, size);
683        assert_eq!(rect_xs(&overlay), [2.0]);
684    }
685
686    /// Recording into a previously used buffer must be indistinguishable
687    /// from recording into a fresh one — for every placement, including the
688    /// marker-splitting paths.
689    #[test]
690    fn reused_storage_records_identically_to_fresh() {
691        let command = DrawCommand::WithContent(recorded_command(|scope| {
692            scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
693            scope.draw_content();
694            scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
695        }));
696        let size = Size::new(10.0, 10.0);
697        for placement in [DrawPlacement::Behind, DrawPlacement::Overlay] {
698            let fresh = primitives_for_placement(&command, placement, size);
699            // Junk in the reused buffer must not leak into the recording.
700            let dirty = vec![DrawPrimitive::Content; 8];
701            let reused = primitives_for_placement_reusing(&command, placement, size, dirty);
702            assert_eq!(fresh, reused);
703        }
704    }
705
706    #[test]
707    fn pushed_batches_keep_marker_count_authoritative() {
708        // A pre-built vector enters through `push_recorded`, which counts the
709        // `Content` marker it carries; the filter path must then strip it.
710        let command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
711            scope.push_recorded(vec![
712                DrawPrimitive::Rect {
713                    rect: rect_at(1.0),
714                    brush: Brush::solid(Color::WHITE),
715                    stroke: None,
716                },
717                DrawPrimitive::Content,
718                DrawPrimitive::Rect {
719                    rect: rect_at(2.0),
720                    brush: Brush::solid(Color::WHITE),
721                    stroke: None,
722                },
723            ]);
724        }));
725        let out = primitives_for_placement(&command, DrawPlacement::Behind, Size::new(10.0, 10.0));
726        assert_eq!(rect_xs(&out), [1.0, 2.0]);
727    }
728}