Skip to main content

cranpose_ui_graphics/
geometry.rs

1//! Geometric primitives: Point, Size, Rect, Insets, Path
2
3use std::{ops::AddAssign, rc::Rc};
4
5use crate::{
6    stroke::{arc_band, ArcGeometry, Stroke},
7    typography::{
8        estimate_text_measurement, DrawTextMeasurer, TextAlign, TextMeasurement, TextStyle,
9        TextVerticalAlign,
10    },
11    Brush, Color, ColorFilter, ImageBitmap, ImageSampling,
12};
13
14const VECTOR_PATH_MASK_CACHE_ENTRIES: usize = 96;
15const VECTOR_PATH_MASK_CACHE_BYTES: usize = 8 * 1024 * 1024;
16
17struct VectorPathMaskCache {
18    entries: Vec<(u64, ImageBitmap)>,
19    bytes: usize,
20}
21
22impl VectorPathMaskCache {
23    const fn new() -> Self {
24        Self {
25            entries: Vec::new(),
26            bytes: 0,
27        }
28    }
29
30    fn get(&mut self, key: u64) -> Option<ImageBitmap> {
31        let index = self.entries.iter().position(|(seen, _)| *seen == key)?;
32        let entry = self.entries.remove(index);
33        let image = entry.1.clone();
34        self.entries.push(entry);
35        Some(image)
36    }
37
38    fn put(&mut self, key: u64, image: ImageBitmap) {
39        let bytes = image.width() as usize * image.height() as usize * 4;
40        if bytes > VECTOR_PATH_MASK_CACHE_BYTES {
41            return;
42        }
43        self.bytes += bytes;
44        self.entries.push((key, image));
45        while self.entries.len() > VECTOR_PATH_MASK_CACHE_ENTRIES
46            || self.bytes > VECTOR_PATH_MASK_CACHE_BYTES
47        {
48            let (_, dropped) = self.entries.remove(0);
49            self.bytes = self
50                .bytes
51                .saturating_sub(dropped.width() as usize * dropped.height() as usize * 4);
52        }
53    }
54}
55
56thread_local! {
57    static VECTOR_PATH_MASKS: std::cell::RefCell<VectorPathMaskCache> =
58        const { std::cell::RefCell::new(VectorPathMaskCache::new()) };
59}
60
61fn vector_path_mask_key(
62    path: &crate::VectorPath,
63    origin: Point,
64    mask_size: (usize, usize),
65    rgb: [u8; 3],
66    alpha: f32,
67) -> u64 {
68    use std::hash::Hasher;
69    let mut hasher = crate::fx_hash::FxHasher::default();
70    hasher.write_u8(path.fill_rule() as u8);
71    hasher.write_u32(origin.x.to_bits());
72    hasher.write_u32(origin.y.to_bits());
73    hasher.write_usize(mask_size.0);
74    hasher.write_usize(mask_size.1);
75    hasher.write(&rgb);
76    hasher.write_u32(alpha.to_bits());
77    for subpath in path.subpaths() {
78        hasher.write_usize(subpath.len());
79        for point in subpath {
80            hasher.write_u32(point.x.to_bits());
81            hasher.write_u32(point.y.to_bits());
82        }
83    }
84    hasher.finish()
85}
86
87fn vector_path_mask_cache_get(key: u64) -> Option<ImageBitmap> {
88    VECTOR_PATH_MASKS.with(|cache| cache.borrow_mut().get(key))
89}
90
91fn vector_path_mask_cache_put(key: u64, image: ImageBitmap) {
92    VECTOR_PATH_MASKS.with(|cache| cache.borrow_mut().put(key, image));
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Default)]
96pub struct Point {
97    pub x: f32,
98    pub y: f32,
99}
100
101impl Point {
102    pub const fn new(x: f32, y: f32) -> Self {
103        Self { x, y }
104    }
105
106    pub const ZERO: Point = Point { x: 0.0, y: 0.0 };
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Default)]
110pub struct Size {
111    pub width: f32,
112    pub height: f32,
113}
114
115impl Size {
116    pub const fn new(width: f32, height: f32) -> Self {
117        Self { width, height }
118    }
119
120    pub const ZERO: Size = Size {
121        width: 0.0,
122        height: 0.0,
123    };
124}
125
126#[derive(Clone, Copy, Debug, PartialEq)]
127pub struct Rect {
128    pub x: f32,
129    pub y: f32,
130    pub width: f32,
131    pub height: f32,
132}
133
134impl Rect {
135    pub fn from_origin_size(origin: Point, size: Size) -> Self {
136        Self {
137            x: origin.x,
138            y: origin.y,
139            width: size.width,
140            height: size.height,
141        }
142    }
143
144    pub fn from_size(size: Size) -> Self {
145        Self {
146            x: 0.0,
147            y: 0.0,
148            width: size.width,
149            height: size.height,
150        }
151    }
152
153    pub fn translate(&self, dx: f32, dy: f32) -> Self {
154        Self {
155            x: self.x + dx,
156            y: self.y + dy,
157            width: self.width,
158            height: self.height,
159        }
160    }
161
162    pub fn contains(&self, x: f32, y: f32) -> bool {
163        x >= self.x && y >= self.y && x <= self.x + self.width && y <= self.y + self.height
164    }
165
166    /// Returns the intersection of two rectangles, or `None` if they don't overlap.
167    pub fn intersect(&self, other: Rect) -> Option<Rect> {
168        let left = self.x.max(other.x);
169        let top = self.y.max(other.y);
170        let right = (self.x + self.width).min(other.x + other.width);
171        let bottom = (self.y + self.height).min(other.y + other.height);
172        let width = right - left;
173        let height = bottom - top;
174        if width <= 0.0 || height <= 0.0 {
175            None
176        } else {
177            Some(Rect {
178                x: left,
179                y: top,
180                width,
181                height,
182            })
183        }
184    }
185
186    pub fn union(&self, other: Rect) -> Rect {
187        let left = self.x.min(other.x);
188        let top = self.y.min(other.y);
189        let right = (self.x + self.width).max(other.x + other.width);
190        let bottom = (self.y + self.height).max(other.y + other.height);
191        Rect {
192            x: left,
193            y: top,
194            width: (right - left).max(0.0),
195            height: (bottom - top).max(0.0),
196        }
197    }
198}
199
200/// Padding values for each edge of a rectangle.
201#[derive(Clone, Copy, Debug, Default, PartialEq)]
202pub struct EdgeInsets {
203    pub left: f32,
204    pub top: f32,
205    pub right: f32,
206    pub bottom: f32,
207}
208
209impl EdgeInsets {
210    pub fn uniform(all: f32) -> Self {
211        Self {
212            left: all,
213            top: all,
214            right: all,
215            bottom: all,
216        }
217    }
218
219    pub fn horizontal(horizontal: f32) -> Self {
220        Self {
221            left: horizontal,
222            right: horizontal,
223            ..Self::default()
224        }
225    }
226
227    pub fn vertical(vertical: f32) -> Self {
228        Self {
229            top: vertical,
230            bottom: vertical,
231            ..Self::default()
232        }
233    }
234
235    pub fn symmetric(horizontal: f32, vertical: f32) -> Self {
236        Self {
237            left: horizontal,
238            right: horizontal,
239            top: vertical,
240            bottom: vertical,
241        }
242    }
243
244    pub fn from_components(left: f32, top: f32, right: f32, bottom: f32) -> Self {
245        Self {
246            left,
247            top,
248            right,
249            bottom,
250        }
251    }
252
253    pub fn is_zero(&self) -> bool {
254        self.left == 0.0 && self.top == 0.0 && self.right == 0.0 && self.bottom == 0.0
255    }
256
257    pub fn horizontal_sum(&self) -> f32 {
258        self.left + self.right
259    }
260
261    pub fn vertical_sum(&self) -> f32 {
262        self.top + self.bottom
263    }
264}
265
266impl AddAssign for EdgeInsets {
267    fn add_assign(&mut self, rhs: Self) {
268        self.left += rhs.left;
269        self.top += rhs.top;
270        self.right += rhs.right;
271        self.bottom += rhs.bottom;
272    }
273}
274
275#[derive(Clone, Copy, Debug, Default, PartialEq)]
276pub struct CornerRadii {
277    pub top_left: f32,
278    pub top_right: f32,
279    pub bottom_right: f32,
280    pub bottom_left: f32,
281}
282
283impl CornerRadii {
284    pub fn uniform(radius: f32) -> Self {
285        Self {
286            top_left: radius,
287            top_right: radius,
288            bottom_right: radius,
289            bottom_left: radius,
290        }
291    }
292}
293
294#[derive(Clone, Copy, Debug, PartialEq)]
295pub struct RoundedCornerShape {
296    radii: CornerRadii,
297}
298
299impl RoundedCornerShape {
300    pub fn new(top_left: f32, top_right: f32, bottom_right: f32, bottom_left: f32) -> Self {
301        Self {
302            radii: CornerRadii {
303                top_left,
304                top_right,
305                bottom_right,
306                bottom_left,
307            },
308        }
309    }
310
311    pub fn uniform(radius: f32) -> Self {
312        Self {
313            radii: CornerRadii::uniform(radius),
314        }
315    }
316
317    pub fn with_radii(radii: CornerRadii) -> Self {
318        Self { radii }
319    }
320
321    pub fn resolve(&self, width: f32, height: f32) -> CornerRadii {
322        let mut resolved = self.radii;
323        let max_width = (width / 2.0).max(0.0);
324        let max_height = (height / 2.0).max(0.0);
325        resolved.top_left = resolved.top_left.clamp(0.0, max_width).min(max_height);
326        resolved.top_right = resolved.top_right.clamp(0.0, max_width).min(max_height);
327        resolved.bottom_right = resolved.bottom_right.clamp(0.0, max_width).min(max_height);
328        resolved.bottom_left = resolved.bottom_left.clamp(0.0, max_width).min(max_height);
329        resolved
330    }
331
332    pub fn radii(&self) -> CornerRadii {
333        self.radii
334    }
335}
336
337#[derive(Clone, Copy, Debug, PartialEq)]
338pub struct TransformOrigin {
339    pub pivot_fraction_x: f32,
340    pub pivot_fraction_y: f32,
341}
342
343impl TransformOrigin {
344    pub const fn new(pivot_fraction_x: f32, pivot_fraction_y: f32) -> Self {
345        Self {
346            pivot_fraction_x,
347            pivot_fraction_y,
348        }
349    }
350
351    pub const CENTER: TransformOrigin = TransformOrigin::new(0.5, 0.5);
352}
353
354impl Default for TransformOrigin {
355    fn default() -> Self {
356        Self::CENTER
357    }
358}
359
360#[derive(Clone, Copy, Debug, Default, PartialEq)]
361pub enum LayerShape {
362    #[default]
363    Rectangle,
364    Rounded(RoundedCornerShape),
365}
366
367#[derive(Clone, Debug, PartialEq)]
368pub struct GraphicsLayer {
369    pub alpha: f32,
370    pub scale: f32,
371    pub scale_x: f32,
372    pub scale_y: f32,
373    pub rotation_x: f32,
374    pub rotation_y: f32,
375    pub rotation_z: f32,
376    pub camera_distance: f32,
377    pub transform_origin: TransformOrigin,
378    pub translation_x: f32,
379    pub translation_y: f32,
380    pub shadow_elevation: f32,
381    pub ambient_shadow_color: Color,
382    pub spot_shadow_color: Color,
383    pub shape: LayerShape,
384    pub clip: bool,
385    pub compositing_strategy: CompositingStrategy,
386    pub blend_mode: BlendMode,
387    pub color_filter: Option<ColorFilter>,
388    pub render_effect: Option<crate::render_effect::RenderEffect>,
389    pub backdrop_effect: Option<crate::render_effect::RenderEffect>,
390}
391
392impl GraphicsLayer {
393    /// The alpha an isolated layer is composited at: an **eight-bit** one,
394    /// truncated.
395    ///
396    /// The platform never composites a layer at a float alpha. HWUI hands an
397    /// isolated `RenderNode` to the rasterizer as
398    /// `canvas->saveLayerAlpha(&bounds, (int)(properties.getAlpha() * 255))`
399    /// (`frameworks/base/libs/hwui/pipeline/skia/RenderNodeDrawable.cpp`,
400    /// `setViewProperties`), and `(int)` truncates — 0.5 composites at 127/255,
401    /// not at 128/255. The fraction below that byte is gone before a single pixel
402    /// is blended, so anything that keeps it lands a level out wherever the byte
403    /// and the float fall on opposite sides of a half.
404    ///
405    /// The sibling branch is a float on purpose: where `getHasOverlappingRendering()`
406    /// is false HWUI takes `*alphaMultiplier = properties.getAlpha()` and folds it
407    /// into each draw without ever making a byte of it. That is what
408    /// `CompositingStrategy::ModulateAlpha` names.
409    ///
410    /// Truncating here and **rounding** in [`Color::srgb_8bit`] is not an
411    /// inconsistency: they are different call sites in the platform. A colour's own
412    /// alpha is snapped by `Color`'s constructor, which adds the half; a layer's
413    /// alpha is snapped by HWUI's cast, which does not. Anything modelling a faded
414    /// layer without allocating one — a canvas drawing a list row's fade by hand,
415    /// say — wants this rule and not the other.
416    pub fn composite_alpha_8bit(alpha: f32) -> f32 {
417        (alpha.clamp(0.0, 1.0) * 255.0).floor() / 255.0
418    }
419}
420
421impl Default for GraphicsLayer {
422    fn default() -> Self {
423        Self {
424            alpha: 1.0,
425            scale: 1.0,
426            scale_x: 1.0,
427            scale_y: 1.0,
428            rotation_x: 0.0,
429            rotation_y: 0.0,
430            rotation_z: 0.0,
431            camera_distance: 8.0,
432            transform_origin: TransformOrigin::CENTER,
433            translation_x: 0.0,
434            translation_y: 0.0,
435            shadow_elevation: 0.0,
436            ambient_shadow_color: Color::BLACK,
437            spot_shadow_color: Color::BLACK,
438            shape: LayerShape::Rectangle,
439            clip: false,
440            compositing_strategy: CompositingStrategy::Auto,
441            blend_mode: BlendMode::SrcOver,
442            color_filter: None,
443            render_effect: None,
444            backdrop_effect: None,
445        }
446    }
447}
448
449/// Blend mode used for draw primitives.
450///
451/// This mirrors Jetpack Compose's blend-mode vocabulary while the renderer
452/// currently guarantees `SrcOver` and `DstOut` behavior.
453#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
454pub enum BlendMode {
455    Clear,
456    Src,
457    Dst,
458    #[default]
459    SrcOver,
460    DstOver,
461    SrcIn,
462    DstIn,
463    SrcOut,
464    DstOut,
465    SrcAtop,
466    DstAtop,
467    Xor,
468    Plus,
469    Modulate,
470    Screen,
471    Overlay,
472    Darken,
473    Lighten,
474    ColorDodge,
475    ColorBurn,
476    HardLight,
477    SoftLight,
478    Difference,
479    Exclusion,
480    Multiply,
481    Hue,
482    Saturation,
483    Color,
484    Luminosity,
485}
486
487/// Controls how a graphics layer is composited into its parent target.
488#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
489pub enum CompositingStrategy {
490    /// Use renderer heuristics (default).
491    #[default]
492    Auto,
493    /// Render this layer to an offscreen target, then composite.
494    Offscreen,
495    /// Multiply alpha on source colors without allocating an offscreen layer.
496    ModulateAlpha,
497}
498
499#[derive(Clone, Debug, PartialEq)]
500pub enum DrawPrimitive {
501    /// Marker emitted by `draw_content()` inside `draw_with_content`.
502    /// This is consumed by the modifier pipeline and never rendered directly.
503    Content,
504    /// Wrapper to associate a draw primitive with a non-default blend mode.
505    Blend {
506        primitive: Box<DrawPrimitive>,
507        blend_mode: BlendMode,
508    },
509    Rect {
510        rect: Rect,
511        brush: Brush,
512        /// `None` fills the rect; `Some` strokes its outline, centered on the
513        /// edge (so it bleeds `width / 2` outside `rect`).
514        stroke: Option<Stroke>,
515    },
516    RoundRect {
517        rect: Rect,
518        brush: Brush,
519        radii: CornerRadii,
520        /// `None` fills the rounded rect; `Some` strokes its outline, centered
521        /// on the edge.
522        stroke: Option<Stroke>,
523    },
524    /// A circular band: a stroked arc, or a filled annular sector / pie wedge.
525    ///
526    /// Angles are radians, `0` = +X, increasing **clockwise** on screen (see
527    /// `crate::stroke` for the full convention).
528    ///
529    /// * `stroke = Some(_)` — the band is `radius ± width/2`, its ends shaped
530    ///   by the stroke cap. `inner_radius` is ignored.
531    /// * `stroke = None` — the band is `inner_radius ..= radius` with flat
532    ///   (butt) radial ends; `inner_radius = 0` is a filled pie wedge.
533    Arc {
534        /// Tight bounding box of the rendered band, caps included. Kept as the
535        /// first field (like every other variant) so bbox/culling/clip logic
536        /// treats an arc exactly like any other primitive.
537        rect: Rect,
538        brush: Brush,
539        center: Point,
540        radius: f32,
541        start_angle: f32,
542        sweep_angle: f32,
543        stroke: Option<Stroke>,
544        /// `> 0` turns a filled wedge into an annular sector.
545        inner_radius: f32,
546    },
547    Image {
548        rect: Rect,
549        image: ImageBitmap,
550        alpha: f32,
551        color_filter: Option<ColorFilter>,
552        sampling: ImageSampling,
553        /// Optional source rectangle in image-pixel coordinates.
554        /// When `None`, the entire image is drawn. When `Some`, only the
555        /// specified sub-region of the source image is sampled.
556        src_rect: Option<Rect>,
557    },
558    /// A laid-out run of text. See [`TextPrimitive`].
559    Text(Box<TextPrimitive>),
560    /// Shadow that requires blur processing. The renderer decides technique
561    /// (GPU blur, CPU approximation, etc.).
562    Shadow(ShadowPrimitive),
563}
564
565/// A run of text, positioned and ready to rasterize.
566///
567/// `rect` is *already resolved*: [`DrawScope::draw_text_at`] measures the
568/// string, applies [`TextStyle::align`] / [`TextStyle::vertical_align`] inside
569/// the requested box, and stores the result here. Renderers therefore lay the
570/// glyphs out from `rect`'s top-left and never re-align — which is what keeps
571/// what [`DrawScope::measure_text`] reported and what lands on screen the same
572/// geometry.
573#[derive(Clone, Debug, PartialEq)]
574pub struct TextPrimitive {
575    /// Tight block box: origin is the top-left of the first line's slot, size
576    /// is the measured size.
577    pub rect: Rect,
578    /// Shared so redrawing an unchanged string each frame clones a pointer
579    /// rather than the characters.
580    pub text: std::rc::Rc<str>,
581    pub style: TextStyle,
582    /// Text is filled with a single color: the glyph atlas path modulates one
583    /// vertex color per glyph. Gradient brushes are resolved to their first
584    /// stop by the draw scope, exactly like [`DrawScope::draw_vector_path`].
585    pub color: Color,
586}
587
588/// Returns a shared `Rc<str>` for `text`, reusing the copy made on an earlier
589/// frame when the content matches.
590///
591/// Apps hand `draw_text*` a `&str` every frame, and a score counter or label
592/// is the same characters frame after frame — without this pool every call
593/// copied them into a fresh `Rc<str>` anyway, defeating the sharing
594/// [`TextPrimitive::text`] exists for. Hits are verified by content, so a hash
595/// collision costs one fresh copy, never the wrong text. The pool clears
596/// itself when full; a live scene re-warms within one frame.
597fn shared_text_str(text: &str) -> Rc<str> {
598    use std::{
599        cell::RefCell,
600        collections::HashMap,
601        hash::{Hash, Hasher},
602    };
603
604    const POOL_CAPACITY: usize = 256;
605    thread_local! {
606        static POOL: RefCell<HashMap<u64, Rc<str>>> = RefCell::new(HashMap::new());
607    }
608
609    let mut hasher = crate::FxHasher::default();
610    text.hash(&mut hasher);
611    let key = hasher.finish();
612
613    POOL.with(|pool| {
614        let mut pool = pool.borrow_mut();
615        if let Some(shared) = pool.get(&key) {
616            if &**shared == text {
617                return Rc::clone(shared);
618            }
619        }
620        let shared: Rc<str> = Rc::from(text);
621        if pool.len() >= POOL_CAPACITY {
622            pool.clear();
623        }
624        pool.insert(key, Rc::clone(&shared));
625        shared
626    })
627}
628
629/// Describes a shadow to be rendered. Each renderer chooses how to blur.
630#[derive(Clone, Debug, PartialEq)]
631pub enum ShadowPrimitive {
632    /// Drop shadow: render shape behind content, blurred. `cutout` knocks
633    /// the element's own (unoffset) shape out of the silhouette before the
634    /// blur so translucent surfaces never sample their own shadow.
635    Drop {
636        shape: Box<DrawPrimitive>,
637        cutout: Option<Box<DrawPrimitive>>,
638        blur_radius: f32,
639        blend_mode: BlendMode,
640    },
641    /// Inner shadow: render fill + cutout to offscreen, blur, clip to bounds.
642    Inner {
643        fill: Box<DrawPrimitive>,
644        cutout: Box<DrawPrimitive>,
645        blur_radius: f32,
646        blend_mode: BlendMode,
647        /// Element bounds — blurred result must be clipped here.
648        clip_rect: Rect,
649    },
650}
651
652pub trait DrawScope {
653    fn size(&self) -> Size;
654    fn draw_content(&mut self);
655    fn draw_rect(&mut self, brush: Brush);
656    fn draw_rect_blend(&mut self, brush: Brush, blend_mode: BlendMode);
657    /// Draws a rectangle at the specified position and size.
658    fn draw_rect_at(&mut self, rect: Rect, brush: Brush);
659    fn draw_rect_at_blend(&mut self, rect: Rect, brush: Brush, blend_mode: BlendMode);
660    fn draw_round_rect(&mut self, brush: Brush, radii: CornerRadii);
661    fn draw_round_rect_blend(&mut self, brush: Brush, radii: CornerRadii, blend_mode: BlendMode);
662    /// Draws a rounded rectangle at the specified position and size.
663    fn draw_round_rect_at(&mut self, rect: Rect, brush: Brush, radii: CornerRadii);
664    fn draw_circle(&mut self, brush: Brush, center: Point, radius: f32);
665    fn draw_circle_blend(
666        &mut self,
667        brush: Brush,
668        center: Point,
669        radius: f32,
670        blend_mode: BlendMode,
671    );
672
673    // ── Stroked outlines ────────────────────────────────────────────────────
674    //
675    // Strokes are *centered* on the geometry: a `width`-wide stroke covers
676    // `width / 2` inside and `width / 2` outside the path, like Skia and
677    // Jetpack Compose. A non-positive or non-finite width draws nothing.
678
679    /// Strokes the outline of the whole scope rect.
680    fn draw_rect_stroked(&mut self, brush: Brush, stroke: Stroke);
681    fn draw_rect_stroked_blend(&mut self, brush: Brush, stroke: Stroke, blend_mode: BlendMode);
682    /// Strokes the outline of `rect`.
683    fn draw_rect_at_stroked(&mut self, rect: Rect, brush: Brush, stroke: Stroke);
684    fn draw_rect_at_stroked_blend(
685        &mut self,
686        rect: Rect,
687        brush: Brush,
688        stroke: Stroke,
689        blend_mode: BlendMode,
690    );
691    /// Strokes the outline of the whole scope rect with rounded corners.
692    fn draw_round_rect_stroked(&mut self, brush: Brush, radii: CornerRadii, stroke: Stroke);
693    fn draw_round_rect_stroked_blend(
694        &mut self,
695        brush: Brush,
696        radii: CornerRadii,
697        stroke: Stroke,
698        blend_mode: BlendMode,
699    );
700    /// Strokes the outline of `rect` with rounded corners.
701    fn draw_round_rect_at_stroked(
702        &mut self,
703        rect: Rect,
704        brush: Brush,
705        radii: CornerRadii,
706        stroke: Stroke,
707    );
708    #[allow(clippy::too_many_arguments)]
709    fn draw_round_rect_at_stroked_blend(
710        &mut self,
711        rect: Rect,
712        brush: Brush,
713        radii: CornerRadii,
714        stroke: Stroke,
715        blend_mode: BlendMode,
716    );
717    /// Strokes a circle outline. Lowers to a stroked rounded rect, so it shares
718    /// the fill pipeline and batches with every other shape.
719    fn draw_circle_stroked(&mut self, brush: Brush, center: Point, radius: f32, stroke: Stroke);
720    fn draw_circle_stroked_blend(
721        &mut self,
722        brush: Brush,
723        center: Point,
724        radius: f32,
725        stroke: Stroke,
726        blend_mode: BlendMode,
727    );
728
729    // ── Arcs ────────────────────────────────────────────────────────────────
730
731    /// Strokes a circular arc.
732    ///
733    /// Angles are in **radians**, `0` points along **+X**, and increasing
734    /// angles sweep **clockwise on screen** (Cranpose uses y-down device
735    /// coordinates, so this matches `atan2(dy, dx)` and the sweep-gradient
736    /// brush). A negative `sweep_angle` sweeps counter-clockwise; `|sweep| >=
737    /// 2π` draws a closed ring.
738    ///
739    /// The stroke is centered on `radius`, so the band covers
740    /// `radius ± width/2`. [`StrokeCap`](crate::StrokeCap) shapes the two ends.
741    /// Nothing is drawn for a zero sweep, a non-positive width, or non-finite
742    /// input.
743    #[allow(clippy::too_many_arguments)]
744    fn draw_arc(
745        &mut self,
746        brush: Brush,
747        center: Point,
748        radius: f32,
749        start_angle: f32,
750        sweep_angle: f32,
751        stroke: Stroke,
752    );
753    #[allow(clippy::too_many_arguments)]
754    fn draw_arc_blend(
755        &mut self,
756        brush: Brush,
757        center: Point,
758        radius: f32,
759        start_angle: f32,
760        sweep_angle: f32,
761        stroke: Stroke,
762        blend_mode: BlendMode,
763    );
764
765    /// Fills an annular sector — the region between `inner_radius` and
766    /// `outer_radius`, limited to an angular sweep, with **flat radial ends**.
767    ///
768    /// This is the shape a stroked arc cannot express: its ends are straight
769    /// lines through the center, not caps. `inner_radius = 0` fills a pie
770    /// wedge. Angle convention is identical to [`draw_arc`](Self::draw_arc).
771    /// Nothing is drawn when `inner_radius >= outer_radius`, the sweep is zero,
772    /// or any input is non-finite.
773    #[allow(clippy::too_many_arguments)]
774    fn draw_annular_sector(
775        &mut self,
776        brush: Brush,
777        center: Point,
778        inner_radius: f32,
779        outer_radius: f32,
780        start_angle: f32,
781        sweep_angle: f32,
782    );
783    #[allow(clippy::too_many_arguments)]
784    fn draw_annular_sector_blend(
785        &mut self,
786        brush: Brush,
787        center: Point,
788        inner_radius: f32,
789        outer_radius: f32,
790        start_angle: f32,
791        sweep_angle: f32,
792        blend_mode: BlendMode,
793    );
794
795    fn draw_image(&mut self, image: ImageBitmap);
796    fn draw_image_blend(&mut self, image: ImageBitmap, blend_mode: BlendMode);
797    fn draw_image_at(
798        &mut self,
799        rect: Rect,
800        image: ImageBitmap,
801        alpha: f32,
802        color_filter: Option<ColorFilter>,
803    );
804    fn draw_image_at_sampled(
805        &mut self,
806        rect: Rect,
807        image: ImageBitmap,
808        alpha: f32,
809        color_filter: Option<ColorFilter>,
810        sampling: ImageSampling,
811    );
812    fn draw_image_at_blend(
813        &mut self,
814        rect: Rect,
815        image: ImageBitmap,
816        alpha: f32,
817        color_filter: Option<ColorFilter>,
818        blend_mode: BlendMode,
819    );
820    /// Draws a sub-region of an image. `src_rect` is in image-pixel
821    /// coordinates; `dst_rect` is in scope coordinates.
822    fn draw_image_src(
823        &mut self,
824        image: ImageBitmap,
825        src_rect: Rect,
826        dst_rect: Rect,
827        alpha: f32,
828        color_filter: Option<ColorFilter>,
829    );
830    fn draw_image_src_sampled(
831        &mut self,
832        image: ImageBitmap,
833        src_rect: Rect,
834        dst_rect: Rect,
835        alpha: f32,
836        color_filter: Option<ColorFilter>,
837        sampling: ImageSampling,
838    );
839    fn draw_image_src_blend(
840        &mut self,
841        image: ImageBitmap,
842        src_rect: Rect,
843        dst_rect: Rect,
844        alpha: f32,
845        color_filter: Option<ColorFilter>,
846        blend_mode: BlendMode,
847    );
848    /// Fills a parsed SVG path in scope coordinates (path units are dp).
849    ///
850    /// The fill is rasterized on the CPU into a supersampled, anti-aliased
851    /// bitmap covering the path bounds and drawn as an image primitive, so
852    /// it works on every render backend. Parse the path once with
853    /// [`crate::VectorPath::parse`] and redraw it per frame. Solid brushes
854    /// are honored exactly; gradient brushes currently fall back to their
855    /// first stop color.
856    fn draw_vector_path(&mut self, path: &crate::VectorPath, brush: Brush);
857    /// Parses SVG path data (the `d` attribute syntax: `M/m L/l H/h V/v
858    /// C/c S/s Q/q T/t A/a Z/z`) and fills it. Invalid path data draws
859    /// nothing. Prefer [`crate::VectorPath::parse`] +
860    /// [`draw_vector_path`](Self::draw_vector_path) to avoid re-parsing
861    /// and to surface parse errors.
862    fn draw_svg_path(&mut self, d: &str, brush: Brush) {
863        if let Ok(path) = crate::VectorPath::parse(d) {
864            self.draw_vector_path(&path, brush);
865        }
866    }
867
868    // ── Text ────────────────────────────────────────────────────────────────
869    //
870    // Text is the one primitive a draw scope cannot resolve on its own: it
871    // needs fonts, which live above this crate. Measurement is therefore
872    // delegated to whatever the UI layer installed on the scope, and the same
873    // measurement decides where `draw_text*` puts the glyphs — so a caller that
874    // centers text from `measure_text` and the renderer that rasterizes it are
875    // reading the same numbers.
876    //
877    // There is deliberately no `_blend` variant: the text draw path has no
878    // blend-mode channel (glyphs are composited `SrcOver` against the atlas
879    // coverage mask), so a blended overload could only lie about what it does.
880
881    /// The block size, line height and first baseline `text` would occupy in
882    /// `style`.
883    ///
884    /// Free to call repeatedly: the underlying text stack caches metrics on
885    /// `(text, style)`, so a game can measure every label every frame to center
886    /// it without touching a font file more than once.
887    fn measure_text(&self, text: &str, style: &TextStyle) -> TextMeasurement;
888
889    /// Draws `text` inside the whole scope rect, positioned by
890    /// [`TextStyle::align`] and [`TextStyle::vertical_align`].
891    fn draw_text(&mut self, brush: Brush, text: &str, style: &TextStyle) {
892        self.draw_text_at(Rect::from_size(self.size()), brush, text, style);
893    }
894
895    /// Draws `text` inside `rect`, positioned by [`TextStyle::align`] and
896    /// [`TextStyle::vertical_align`].
897    ///
898    /// The glyphs are *not* clipped to `rect` — it is an alignment box, not a
899    /// viewport. A `rect` narrower than the measured text overflows in the
900    /// direction the alignment implies; clip the layer if that matters.
901    fn draw_text_at(&mut self, rect: Rect, brush: Brush, text: &str, style: &TextStyle);
902
903    /// Draws `text` with the top-left corner of its block at `top_left`.
904    ///
905    /// Alignment is a no-op here because the box is the measurement — this is
906    /// the "I already know where it goes" form, and the one to pair with
907    /// [`measure_text`](Self::measure_text) for hand-rolled centering.
908    fn draw_text_from(&mut self, top_left: Point, brush: Brush, text: &str, style: &TextStyle) {
909        if text.is_empty() {
910            return;
911        }
912        let measurement = self.measure_text(text, style);
913        self.draw_text_at(
914            Rect::from_origin_size(top_left, measurement.size),
915            brush,
916            text,
917            &TextStyle {
918                align: TextAlign::Left,
919                vertical_align: TextVerticalAlign::Top,
920                ..style.clone()
921            },
922        );
923    }
924
925    fn into_primitives(self) -> Vec<DrawPrimitive>;
926}
927
928/// Resolves the top-left corner a text block of `measurement` gets when it is
929/// aligned inside `rect`.
930///
931/// Split out so the placement rule is stated once and can be unit-tested
932/// against the measurement it is derived from.
933pub fn align_text_block(rect: Rect, measurement: TextMeasurement, style: &TextStyle) -> Point {
934    let x = match style.align {
935        TextAlign::Left => rect.x,
936        TextAlign::Center => rect.x + (rect.width - measurement.size.width) * 0.5,
937        TextAlign::Right => rect.x + rect.width - measurement.size.width,
938    };
939    let y = match style.vertical_align {
940        TextVerticalAlign::Top => rect.y,
941        TextVerticalAlign::Center => rect.y + (rect.height - measurement.size.height) * 0.5,
942        TextVerticalAlign::Bottom => rect.y + rect.height - measurement.size.height,
943        TextVerticalAlign::Baseline => rect.y - measurement.first_baseline,
944    };
945    Point::new(x, y)
946}
947
948/// Which typed store holds one recorded draw. The tape preserves the exact
949/// draw order across the per-kind stores.
950#[derive(Clone, Copy, Debug, PartialEq, Eq)]
951#[repr(u8)]
952pub(crate) enum RecordKind {
953    SolidRect,
954    SolidRoundRect,
955    SolidArc,
956    Other,
957}
958
959/// Tagged reference to one recorded draw: kind in the top 2 bits, index into
960/// that kind's typed store in the low 30. Four bytes per entry buys direct
961/// dispatch everywhere a tape range is consumed — no per-kind cursor walks,
962/// no per-frame view tables (sol P4a).
963#[derive(Clone, Copy, Debug, PartialEq, Eq)]
964pub(crate) struct TapeRef(u32);
965
966impl TapeRef {
967    const KIND_SHIFT: u32 = 30;
968    const INDEX_MASK: u32 = (1 << Self::KIND_SHIFT) - 1;
969
970    pub(crate) fn new(kind: RecordKind, index: usize) -> Self {
971        debug_assert!(index < (1usize << Self::KIND_SHIFT));
972        Self(((kind as u32) << Self::KIND_SHIFT) | index as u32)
973    }
974
975    pub(crate) fn kind(self) -> RecordKind {
976        match self.0 >> Self::KIND_SHIFT {
977            0 => RecordKind::SolidRect,
978            1 => RecordKind::SolidRoundRect,
979            2 => RecordKind::SolidArc,
980            _ => RecordKind::Other,
981        }
982    }
983
984    pub(crate) fn index(self) -> usize {
985        (self.0 & Self::INDEX_MASK) as usize
986    }
987
988    /// The packed word itself. Because per-store indices appear on the tape
989    /// in strictly increasing order (see [`CommandRecording::tape`]), `d`
990    /// consecutive tape entries are one kind's run exactly when
991    /// `tape[p + d].raw() == tape[p].raw() + d` — same kind bits, index
992    /// advanced by every step. Span verification decomposes tape ranges into
993    /// per-kind contiguous store runs on this single comparison.
994    pub(crate) fn raw(self) -> u32 {
995        self.0
996    }
997}
998
999/// A solid `SrcOver` rect, recorded as raw values.
1000#[derive(Clone, Copy, Debug, PartialEq)]
1001pub struct SolidRectRecord {
1002    pub rect: Rect,
1003    pub color: Color,
1004    pub stroke: Option<Stroke>,
1005}
1006
1007/// A solid `SrcOver` rounded rect (also the lowering of `draw_circle`),
1008/// recorded as raw values.
1009#[derive(Clone, Copy, Debug, PartialEq)]
1010pub struct SolidRoundRectRecord {
1011    pub rect: Rect,
1012    pub radii: CornerRadii,
1013    pub color: Color,
1014    pub stroke: Option<Stroke>,
1015}
1016
1017/// A solid `SrcOver` arc, recorded as the RAW draw parameters — before
1018/// band resolution, tight-bounds trigonometry, or the degeneracy check,
1019/// all of which happen at materialization. Retention verification must be
1020/// able to compare what the app said, ahead of everything deriving from it.
1021#[derive(Clone, Copy, Debug, PartialEq)]
1022pub struct SolidArcRecord {
1023    pub center: Point,
1024    pub radius: f32,
1025    pub start_angle: f32,
1026    pub sweep_angle: f32,
1027    pub inner_radius: f32,
1028    pub color: Color,
1029    pub stroke: Option<Stroke>,
1030}
1031
1032/// One draw command's recording in compact typed form: pure-numeric records
1033/// for the common solid shapes (no `Brush` destructor branch, roughly half
1034/// the bytes of the `DrawPrimitive` they materialize into) and ordinary
1035/// primitives for everything else, with `tape` preserving global order. The
1036/// index each tape entry carries into its per-kind store is the stable
1037/// compact handle for the rare resource-bearing entries (`others` holds
1038/// gradients, images, text, blends, and content markers whole).
1039#[derive(Clone, Debug, Default)]
1040pub struct CommandRecording {
1041    /// INVARIANT: per-store indices appear on the tape in strictly
1042    /// increasing order (0, 1, 2, ... per kind) — recording appends only.
1043    /// Sequential consumers (`finish`'s `others.drain(..)`) rely on it;
1044    /// random-access consumers use the index directly.
1045    pub(crate) tape: Vec<TapeRef>,
1046    pub(crate) rects: Vec<SolidRectRecord>,
1047    pub(crate) round_rects: Vec<SolidRoundRectRecord>,
1048    pub(crate) arcs: Vec<SolidArcRecord>,
1049    pub(crate) others: Vec<DrawPrimitive>,
1050}
1051
1052impl CommandRecording {
1053    /// Total recorded entries (the tape length).
1054    pub fn len(&self) -> usize {
1055        self.tape.len()
1056    }
1057
1058    /// Materializes one tape range into fresh primitives — the emergency
1059    /// path for a bypassed span whose retained draw fell through. `None`
1060    /// when the range does not lie within this recording (cleared buffers,
1061    /// stale range).
1062    pub fn materialize_range(
1063        &self,
1064        tape_start: usize,
1065        tape_end: usize,
1066    ) -> Option<Vec<DrawPrimitive>> {
1067        if tape_start > tape_end || tape_end > self.tape.len() {
1068            return None;
1069        }
1070        let mut out = Vec::with_capacity(tape_end - tape_start);
1071        for entry in &self.tape[tape_start..tape_end] {
1072            match entry.kind() {
1073                RecordKind::SolidRect => {
1074                    let record = self.rects.get(entry.index())?;
1075                    out.push(DrawPrimitive::Rect {
1076                        rect: record.rect,
1077                        brush: Brush::Solid(record.color),
1078                        stroke: record.stroke,
1079                    });
1080                }
1081                RecordKind::SolidRoundRect => {
1082                    let record = self.round_rects.get(entry.index())?;
1083                    out.push(DrawPrimitive::RoundRect {
1084                        rect: record.rect,
1085                        brush: Brush::Solid(record.color),
1086                        radii: record.radii,
1087                        stroke: record.stroke,
1088                    });
1089                }
1090                RecordKind::SolidArc => {
1091                    let record = self.arcs.get(entry.index())?;
1092                    if let Some(primitive) = materialize_solid_arc(record) {
1093                        out.push(primitive);
1094                    }
1095                }
1096                RecordKind::Other => {
1097                    out.push(self.others.get(entry.index())?.clone());
1098                }
1099            }
1100        }
1101        Some(out)
1102    }
1103
1104    pub fn is_empty(&self) -> bool {
1105        self.tape.is_empty()
1106    }
1107
1108    /// Test support: the identity of the tape's allocation, so buffer-reuse
1109    /// tests can assert that re-recording ping-pongs between the same two
1110    /// allocations instead of growing fresh ones every frame.
1111    #[doc(hidden)]
1112    pub fn tape_ptr(&self) -> *const u8 {
1113        self.tape.as_ptr() as *const u8
1114    }
1115
1116    fn clear(&mut self) {
1117        self.tape.clear();
1118        self.rects.clear();
1119        self.round_rects.clear();
1120        self.arcs.clear();
1121        self.others.clear();
1122    }
1123
1124    /// A buffer-reusing deep copy: `clone_from` on every store, so
1125    /// refreshing a long-lived recording (the replay snapshot, twice per
1126    /// convergence cycle on a heavy scene's ~17k-record tape) truncates and
1127    /// copies into the existing allocations instead of cloning five fresh
1128    /// buffers. Semantically identical to `*self = source.clone()`.
1129    pub(crate) fn clone_records_from(&mut self, source: &Self) {
1130        self.tape.clone_from(&source.tape);
1131        self.rects.clone_from(&source.rects);
1132        self.round_rects.clone_from(&source.round_rects);
1133        self.arcs.clone_from(&source.arcs);
1134        self.others.clone_from(&source.others);
1135    }
1136}
1137
1138/// What [`DrawScopeDefault::finish`] hands back: the materialized primitives,
1139/// the marker count that travels with them, and the recording buffers so a
1140/// retaining caller can lend them to the same command's next recording.
1141pub struct FinishedRecording {
1142    pub primitives: Vec<DrawPrimitive>,
1143    pub content_markers: u32,
1144    pub recording: CommandRecording,
1145    /// Tape indices that materialized to nothing (degenerate arcs), in
1146    /// ascending order — empty in practice. Consumers translating tape
1147    /// ranges into primitive ranges subtract the drops before each
1148    /// boundary.
1149    pub dropped: Vec<u32>,
1150}
1151
1152#[derive(Default)]
1153pub struct DrawScopeDefault {
1154    size: Size,
1155    /// The compact recording every draw call writes into; materialized into
1156    /// `out` once, when the scope finishes.
1157    rec: CommandRecording,
1158    /// Materialization target, owned by the consumer across frames (see
1159    /// [`Self::with_recording`]); untouched until [`Self::finish`].
1160    out: Vec<DrawPrimitive>,
1161    /// How many [`DrawPrimitive::Content`] markers this scope has recorded.
1162    /// Consumers splitting a command around its content would otherwise have
1163    /// to re-scan thousands of just-recorded primitives to learn "none".
1164    content_markers: u32,
1165    /// `None` falls back to [`estimate_text_measurement`]. Every scope the
1166    /// framework builds carries the app's real measurer; a hand-built one
1167    /// (tests, tooling) does not have to.
1168    text_measurer: Option<Rc<dyn DrawTextMeasurer>>,
1169}
1170
1171/// Per-thread memory of how many primitives the scope of a given size emitted
1172/// last time, keyed by the scope's size bits. Draw closures re-record every
1173/// frame, and a heavy animated canvas emits thousands of primitives; starting
1174/// its vector at the previous count skips the whole doubling schedule of
1175/// reallocations. Keying by size keeps a small HUD scope from inheriting the
1176/// arena's multi-thousand capacity.
1177const RECORDED_PRIMITIVE_COUNTS_LIMIT: usize = 64;
1178
1179thread_local! {
1180    static RECORDED_PRIMITIVE_COUNTS: std::cell::RefCell<std::collections::HashMap<(u32, u32), usize>> =
1181        std::cell::RefCell::new(std::collections::HashMap::new());
1182}
1183
1184fn recorded_primitive_capacity(size: Size) -> usize {
1185    RECORDED_PRIMITIVE_COUNTS.with(|counts| {
1186        counts
1187            .borrow()
1188            .get(&(size.width.to_bits(), size.height.to_bits()))
1189            .copied()
1190            .unwrap_or(0)
1191    })
1192}
1193
1194fn note_recorded_primitive_count(size: Size, count: usize) {
1195    RECORDED_PRIMITIVE_COUNTS.with(|counts| {
1196        let mut counts = counts.borrow_mut();
1197        if counts.len() >= RECORDED_PRIMITIVE_COUNTS_LIMIT {
1198            counts.clear();
1199        }
1200        counts.insert((size.width.to_bits(), size.height.to_bits()), count);
1201    });
1202}
1203
1204impl DrawScopeDefault {
1205    pub fn new(size: Size) -> Self {
1206        Self::with_recording(size, None, CommandRecording::default(), Vec::new())
1207    }
1208
1209    /// A scope that measures text with the app's fonts.
1210    ///
1211    /// The framework calls this for every draw closure it runs; `new` exists
1212    /// for callers that never draw text.
1213    pub fn with_text_measurer(size: Size, text_measurer: Rc<dyn DrawTextMeasurer>) -> Self {
1214        Self::with_recording(
1215            size,
1216            Some(text_measurer),
1217            CommandRecording::default(),
1218            Vec::new(),
1219        )
1220    }
1221
1222    /// Like [`with_text_measurer`](Self::with_text_measurer), but records
1223    /// into storage the caller already owns. This is the retained-recording
1224    /// path: a command that re-records every frame keeps one buffer whose
1225    /// capacity was earned on earlier frames, instead of walking a fresh
1226    /// vector through the whole doubling schedule again.
1227    pub fn with_text_measurer_reusing(
1228        size: Size,
1229        text_measurer: Rc<dyn DrawTextMeasurer>,
1230        storage: Vec<DrawPrimitive>,
1231    ) -> Self {
1232        Self::with_recording(
1233            size,
1234            Some(text_measurer),
1235            CommandRecording::default(),
1236            storage,
1237        )
1238    }
1239
1240    /// The full retained-recording form: compact recording buffers AND the
1241    /// materialization target both come from the caller, so a command that
1242    /// re-records every frame allocates nothing in the steady state.
1243    pub fn with_recording(
1244        size: Size,
1245        text_measurer: Option<Rc<dyn DrawTextMeasurer>>,
1246        mut recording: CommandRecording,
1247        out: Vec<DrawPrimitive>,
1248    ) -> Self {
1249        recording.clear();
1250        recording.tape.reserve(recorded_primitive_capacity(size));
1251        Self {
1252            size,
1253            rec: recording,
1254            out,
1255            content_markers: 0,
1256            text_measurer,
1257        }
1258    }
1259
1260    /// How many [`DrawPrimitive::Content`] markers this scope has recorded.
1261    /// Command consumers split placements around the count instead of
1262    /// re-scanning thousands of just-recorded primitives to learn "none".
1263    pub fn content_marker_count(&self) -> u32 {
1264        self.content_markers
1265    }
1266
1267    /// The compact recording as recorded so far. Retention verification
1268    /// reads this before materialization decides what to skip.
1269    pub fn recorded(&self) -> &CommandRecording {
1270        &self.rec
1271    }
1272
1273    /// Appends already-recorded primitives verbatim. This is the replay path
1274    /// for pre-built primitive lists (deferred modifier draws recorded
1275    /// earlier, synthesized commands in tests); the marker count stays
1276    /// authoritative because the batch is scanned once on the way in.
1277    pub fn push_recorded(&mut self, primitives: Vec<DrawPrimitive>) {
1278        self.content_markers += primitives
1279            .iter()
1280            .filter(|primitive| matches!(primitive, DrawPrimitive::Content))
1281            .count() as u32;
1282        let base = self.rec.others.len();
1283        self.rec
1284            .tape
1285            .extend((0..primitives.len()).map(|i| TapeRef::new(RecordKind::Other, base + i)));
1286        self.rec.others.extend(primitives);
1287    }
1288
1289    /// Materializes the recording into the `out` storage and hands both
1290    /// back, plus the compact buffers for the caller to retain. This — not
1291    /// recording — is where solid records become `DrawPrimitive`s and where
1292    /// arc bands, tight bounds, and the degeneracy drop happen, so the
1293    /// output is exactly what recording used to produce directly.
1294    pub fn finish(mut self) -> FinishedRecording {
1295        // Composition diagnostic for retention work: how much of a heavy
1296        // command is typed records vs ordinary primitives.
1297        if std::env::var_os("CRANPOSE_RECORD_MIX_DIAG").is_some() && self.rec.tape.len() > 400 {
1298            eprintln!(
1299                "[record-mix] tape={} rects={} round_rects={} arcs={} others={}",
1300                self.rec.tape.len(),
1301                self.rec.rects.len(),
1302                self.rec.round_rects.len(),
1303                self.rec.arcs.len(),
1304                self.rec.others.len(),
1305            );
1306        }
1307        let mut out = std::mem::take(&mut self.out);
1308        out.clear();
1309        out.reserve(self.rec.tape.len());
1310        let mut dropped: Vec<u32> = Vec::new();
1311        {
1312            // `others` moves out via drain; the increasing-index invariant
1313            // on the tape makes tape order equal drain order. Copy stores
1314            // are addressed directly by the entry's index.
1315            let mut others = self.rec.others.drain(..);
1316            for (tape_index, entry) in self.rec.tape.iter().enumerate() {
1317                match entry.kind() {
1318                    RecordKind::SolidRect => {
1319                        let record = &self.rec.rects[entry.index()];
1320                        out.push(DrawPrimitive::Rect {
1321                            rect: record.rect,
1322                            brush: Brush::Solid(record.color),
1323                            stroke: record.stroke,
1324                        });
1325                    }
1326                    RecordKind::SolidRoundRect => {
1327                        let record = &self.rec.round_rects[entry.index()];
1328                        out.push(DrawPrimitive::RoundRect {
1329                            rect: record.rect,
1330                            brush: Brush::Solid(record.color),
1331                            radii: record.radii,
1332                            stroke: record.stroke,
1333                        });
1334                    }
1335                    RecordKind::SolidArc => {
1336                        let record = &self.rec.arcs[entry.index()];
1337                        if let Some(primitive) = materialize_solid_arc(record) {
1338                            out.push(primitive);
1339                        } else {
1340                            dropped.push(tape_index as u32);
1341                        }
1342                    }
1343                    RecordKind::Other => {
1344                        out.push(others.next().expect("tape/others in sync"));
1345                    }
1346                }
1347            }
1348        }
1349        self.rec.clear();
1350        note_recorded_primitive_count(self.size, out.len());
1351        FinishedRecording {
1352            primitives: out,
1353            content_markers: self.content_markers,
1354            recording: self.rec,
1355            dropped,
1356        }
1357    }
1358
1359    /// Like [`Self::finish`], but materializing nothing: the consumer is
1360    /// about to re-emit a PREVIOUS frame's saved emission in place of this
1361    /// recording (the stale-transition serve on a replay collapse frame),
1362    /// so building this frame's primitives — the very cost the serve
1363    /// exists to skip — would be pure waste. The recording and
1364    /// materialization buffers still return, cleared exactly as
1365    /// [`Self::finish`] leaves them, so the command's steady-state
1366    /// ping-pong keeps its earned capacity.
1367    pub fn finish_recording_only(mut self) -> FinishedRecording {
1368        let mut out = std::mem::take(&mut self.out);
1369        out.clear();
1370        note_recorded_primitive_count(self.size, self.rec.tape.len());
1371        self.rec.clear();
1372        FinishedRecording {
1373            primitives: out,
1374            content_markers: self.content_markers,
1375            recording: self.rec,
1376            dropped: Vec::new(),
1377        }
1378    }
1379
1380    /// Like [`Self::finish`], but for a verified command: a retained span
1381    /// whose slot `bypass` approves is NOT materialized — its records cease
1382    /// to exist as per-frame primitives, which is the entire point of
1383    /// retention — and the replay frame comes back with primitive ranges
1384    /// assigned during the same single tape walk. Every other span
1385    /// materializes exactly as [`Self::finish`] would. `AllDynamic`
1386    /// degenerates to plain `finish`.
1387    pub fn finish_replay(
1388        mut self,
1389        center: Point,
1390        outcome: crate::record_replay::ReplayOutcome,
1391        bypass: &mut dyn FnMut(u32) -> bool,
1392    ) -> (
1393        FinishedRecording,
1394        Option<crate::record_replay::CommandReplayFrame>,
1395    ) {
1396        use crate::record_replay::{CommandReplayFrame, FrameSpan, ReplayOutcome, ReplaySpan};
1397        let ReplayOutcome::Spans(replay_spans) = outcome else {
1398            return (self.finish(), None);
1399        };
1400        let tape_len = self.rec.tape.len();
1401        let mut out = std::mem::take(&mut self.out);
1402        out.clear();
1403        let mut dropped: Vec<u32> = Vec::new();
1404        let mut spans: Vec<FrameSpan> = Vec::with_capacity(replay_spans.len());
1405        let mut any_retained = false;
1406        {
1407            // `others` moves out via drain (tape order equals drain order by
1408            // the increasing-index invariant); Copy stores are addressed
1409            // directly by each entry's index, so a bypassed span costs
1410            // nothing to step past.
1411            let mut others = self.rec.others.drain(..);
1412            let tape = &self.rec.tape;
1413            let rects = &self.rec.rects;
1414            let round_rects = &self.rec.round_rects;
1415            let arcs = &self.rec.arcs;
1416            // Materializes one contiguous tape range into `out`. Kept as a
1417            // macro so `out`, `dropped`, and the `others` cursor stay plain
1418            // locals the borrow checker can split by field.
1419            macro_rules! materialize_range {
1420                ($start:expr, $end:expr) => {{
1421                    let prim_start = out.len() as u32;
1422                    for tape_index in $start..$end {
1423                        let entry = tape[tape_index];
1424                        match entry.kind() {
1425                            RecordKind::SolidRect => {
1426                                let record = &rects[entry.index()];
1427                                out.push(DrawPrimitive::Rect {
1428                                    rect: record.rect,
1429                                    brush: Brush::Solid(record.color),
1430                                    stroke: record.stroke,
1431                                });
1432                            }
1433                            RecordKind::SolidRoundRect => {
1434                                let record = &round_rects[entry.index()];
1435                                out.push(DrawPrimitive::RoundRect {
1436                                    rect: record.rect,
1437                                    brush: Brush::Solid(record.color),
1438                                    radii: record.radii,
1439                                    stroke: record.stroke,
1440                                });
1441                            }
1442                            RecordKind::SolidArc => {
1443                                let record = &arcs[entry.index()];
1444                                if let Some(primitive) = materialize_solid_arc(record) {
1445                                    out.push(primitive);
1446                                } else {
1447                                    dropped.push(tape_index as u32);
1448                                }
1449                            }
1450                            RecordKind::Other => {
1451                                out.push(others.next().expect("tape/others in sync"));
1452                            }
1453                        }
1454                    }
1455                    (prim_start, out.len() as u32)
1456                }};
1457            }
1458            for span in replay_spans {
1459                match span {
1460                    ReplaySpan::Dynamic {
1461                        tape_start,
1462                        tape_end,
1463                    } => {
1464                        let range = materialize_range!(tape_start, tape_end);
1465                        if range.1 > range.0 {
1466                            spans.push(FrameSpan::Dynamic { range });
1467                        }
1468                    }
1469                    ReplaySpan::Retained {
1470                        slot,
1471                        capture,
1472                        slot_offset,
1473                        tape_start,
1474                        tape_end,
1475                        transform,
1476                        recolors,
1477                        bounds,
1478                    } => {
1479                        // A retained span holds solid arcs and circles by
1480                        // construction; anything else in its range means
1481                        // the ordinary path must draw it.
1482                        let compact = tape[tape_start..tape_end]
1483                            .iter()
1484                            .all(|entry| entry.kind() != RecordKind::Other);
1485                        if compact && !capture && bypass(slot) {
1486                            // The bypass: the records are never materialized
1487                            // — direct indexing leaves nothing to advance.
1488                            // Capture guaranteed each record one clean
1489                            // shape, so no drop tracking is needed on the
1490                            // way past.
1491                            any_retained = true;
1492                            let position = out.len() as u32;
1493                            spans.push(FrameSpan::Retained {
1494                                slot,
1495                                capture: false,
1496                                slot_offset: slot_offset as u32,
1497                                range: (position, position),
1498                                tape_range: (tape_start as u32, tape_end as u32),
1499                                transform,
1500                                recolors,
1501                                bounds,
1502                            });
1503                            continue;
1504                        }
1505                        let drops_before = dropped.len();
1506                        let range = materialize_range!(tape_start, tape_end);
1507                        if compact && dropped.len() == drops_before {
1508                            any_retained = true;
1509                            spans.push(FrameSpan::Retained {
1510                                slot,
1511                                capture,
1512                                slot_offset: slot_offset as u32,
1513                                range,
1514                                tape_range: (tape_start as u32, tape_end as u32),
1515                                transform,
1516                                recolors,
1517                                bounds,
1518                            });
1519                        } else if range.1 > range.0 {
1520                            spans.push(FrameSpan::Dynamic { range });
1521                        }
1522                    }
1523                }
1524            }
1525        }
1526        // Deliberately NOT cleared: the typed stores must survive until the
1527        // renderer has drawn this frame, so a bypassed span that cannot be
1528        // drawn retained (context drift, op cap) can still be materialized
1529        // on demand from the recording — which the consumer publishes and
1530        // pins to the frame as its owned `fallback`. The next recording's
1531        // scope clears the buffers on construction anyway.
1532        note_recorded_primitive_count(self.size, tape_len);
1533        // `fallback` is attached by the consumer once the recording is
1534        // published under its shared handle — the recording is still owned
1535        // by value here.
1536        let frame = any_retained.then_some(CommandReplayFrame {
1537            center,
1538            spans,
1539            fallback: None,
1540        });
1541        (
1542            FinishedRecording {
1543                primitives: out,
1544                content_markers: self.content_markers,
1545                recording: self.rec,
1546                dropped,
1547            },
1548            frame,
1549        )
1550    }
1551
1552    fn push_other(&mut self, primitive: DrawPrimitive) {
1553        let idx = self.rec.others.len();
1554        self.rec.tape.push(TapeRef::new(RecordKind::Other, idx));
1555        self.rec.others.push(primitive);
1556    }
1557
1558    fn push_blended_primitive(&mut self, primitive: DrawPrimitive, blend_mode: BlendMode) {
1559        if blend_mode != BlendMode::SrcOver {
1560            self.push_other(DrawPrimitive::Blend {
1561                primitive: Box::new(primitive),
1562                blend_mode,
1563            });
1564            return;
1565        }
1566        match primitive {
1567            DrawPrimitive::Rect {
1568                rect,
1569                brush: Brush::Solid(color),
1570                stroke,
1571            } => {
1572                let idx = self.rec.rects.len();
1573                self.rec.tape.push(TapeRef::new(RecordKind::SolidRect, idx));
1574                self.rec.rects.push(SolidRectRecord {
1575                    rect,
1576                    color,
1577                    stroke,
1578                });
1579            }
1580            DrawPrimitive::RoundRect {
1581                rect,
1582                brush: Brush::Solid(color),
1583                radii,
1584                stroke,
1585            } => {
1586                let idx = self.rec.round_rects.len();
1587                self.rec
1588                    .tape
1589                    .push(TapeRef::new(RecordKind::SolidRoundRect, idx));
1590                self.rec.round_rects.push(SolidRoundRectRecord {
1591                    rect,
1592                    radii,
1593                    color,
1594                    stroke,
1595                });
1596            }
1597            other => self.push_other(other),
1598        }
1599    }
1600
1601    /// Shared lowering for [`DrawScope::draw_arc`] and
1602    /// [`DrawScope::draw_annular_sector`].
1603    ///
1604    /// Resolves the band, computes the *tight* bounding box (caps included) and
1605    /// drops degenerate geometry on the floor instead of emitting NaN-bearing
1606    /// primitives the renderers would have to defend against.
1607    #[allow(clippy::too_many_arguments)]
1608    fn push_arc(
1609        &mut self,
1610        brush: Brush,
1611        center: Point,
1612        radius: f32,
1613        start_angle: f32,
1614        sweep_angle: f32,
1615        stroke: Option<Stroke>,
1616        inner_radius: f32,
1617        blend_mode: BlendMode,
1618    ) {
1619        // The common case records raw parameters only; band resolution,
1620        // tight bounds, and the degeneracy drop run at materialization
1621        // (see [`materialize_solid_arc`]), producing identical output.
1622        if blend_mode == BlendMode::SrcOver {
1623            if let Brush::Solid(color) = brush {
1624                let idx = self.rec.arcs.len();
1625                self.rec.tape.push(TapeRef::new(RecordKind::SolidArc, idx));
1626                self.rec.arcs.push(SolidArcRecord {
1627                    center,
1628                    radius,
1629                    start_angle,
1630                    sweep_angle,
1631                    inner_radius,
1632                    color,
1633                    stroke,
1634                });
1635                return;
1636            }
1637        }
1638        let (band_inner, band_outer, cap) = arc_band(radius, inner_radius, stroke);
1639        let geometry = ArcGeometry::new(
1640            center,
1641            band_inner,
1642            band_outer,
1643            start_angle,
1644            sweep_angle,
1645            cap,
1646        );
1647        if geometry.is_degenerate() {
1648            return;
1649        }
1650        self.push_blended_primitive(
1651            DrawPrimitive::Arc {
1652                rect: geometry.bounds(),
1653                brush,
1654                center,
1655                radius,
1656                start_angle,
1657                sweep_angle,
1658                stroke,
1659                inner_radius,
1660            },
1661            blend_mode,
1662        );
1663    }
1664}
1665
1666/// The deferred half of the solid-arc fast path: exactly the lowering
1667/// [`DrawScopeDefault::push_arc`] applies to every other arc, run when the
1668/// recording materializes instead of when the app draws. `None` is the
1669/// degenerate drop.
1670fn materialize_solid_arc(record: &SolidArcRecord) -> Option<DrawPrimitive> {
1671    let (band_inner, band_outer, cap) = arc_band(record.radius, record.inner_radius, record.stroke);
1672    let geometry = ArcGeometry::new(
1673        record.center,
1674        band_inner,
1675        band_outer,
1676        record.start_angle,
1677        record.sweep_angle,
1678        cap,
1679    );
1680    if geometry.is_degenerate() {
1681        return None;
1682    }
1683    Some(DrawPrimitive::Arc {
1684        rect: geometry.bounds(),
1685        brush: Brush::Solid(record.color),
1686        center: record.center,
1687        radius: record.radius,
1688        start_angle: record.start_angle,
1689        sweep_angle: record.sweep_angle,
1690        stroke: record.stroke,
1691        inner_radius: record.inner_radius,
1692    })
1693}
1694
1695impl DrawScope for DrawScopeDefault {
1696    fn size(&self) -> Size {
1697        self.size
1698    }
1699
1700    fn draw_content(&mut self) {
1701        self.content_markers += 1;
1702        self.push_other(DrawPrimitive::Content);
1703    }
1704
1705    fn draw_rect(&mut self, brush: Brush) {
1706        self.draw_rect_blend(brush, BlendMode::SrcOver);
1707    }
1708
1709    fn draw_rect_blend(&mut self, brush: Brush, blend_mode: BlendMode) {
1710        self.push_blended_primitive(
1711            DrawPrimitive::Rect {
1712                rect: Rect::from_size(self.size),
1713                brush,
1714                stroke: None,
1715            },
1716            blend_mode,
1717        );
1718    }
1719
1720    fn draw_rect_at(&mut self, rect: Rect, brush: Brush) {
1721        self.draw_rect_at_blend(rect, brush, BlendMode::SrcOver);
1722    }
1723
1724    fn draw_rect_at_blend(&mut self, rect: Rect, brush: Brush, blend_mode: BlendMode) {
1725        self.push_blended_primitive(
1726            DrawPrimitive::Rect {
1727                rect,
1728                brush,
1729                stroke: None,
1730            },
1731            blend_mode,
1732        );
1733    }
1734
1735    fn draw_round_rect(&mut self, brush: Brush, radii: CornerRadii) {
1736        self.draw_round_rect_blend(brush, radii, BlendMode::SrcOver);
1737    }
1738
1739    fn draw_round_rect_blend(&mut self, brush: Brush, radii: CornerRadii, blend_mode: BlendMode) {
1740        self.push_blended_primitive(
1741            DrawPrimitive::RoundRect {
1742                rect: Rect::from_size(self.size),
1743                brush,
1744                radii,
1745                stroke: None,
1746            },
1747            blend_mode,
1748        );
1749    }
1750
1751    fn draw_round_rect_at(&mut self, rect: Rect, brush: Brush, radii: CornerRadii) {
1752        self.push_blended_primitive(
1753            DrawPrimitive::RoundRect {
1754                rect,
1755                brush,
1756                radii,
1757                stroke: None,
1758            },
1759            BlendMode::SrcOver,
1760        );
1761    }
1762
1763    fn draw_rect_stroked(&mut self, brush: Brush, stroke: Stroke) {
1764        self.draw_rect_stroked_blend(brush, stroke, BlendMode::SrcOver);
1765    }
1766
1767    fn draw_rect_stroked_blend(&mut self, brush: Brush, stroke: Stroke, blend_mode: BlendMode) {
1768        self.draw_rect_at_stroked_blend(Rect::from_size(self.size), brush, stroke, blend_mode);
1769    }
1770
1771    fn draw_rect_at_stroked(&mut self, rect: Rect, brush: Brush, stroke: Stroke) {
1772        self.draw_rect_at_stroked_blend(rect, brush, stroke, BlendMode::SrcOver);
1773    }
1774
1775    fn draw_rect_at_stroked_blend(
1776        &mut self,
1777        rect: Rect,
1778        brush: Brush,
1779        stroke: Stroke,
1780        blend_mode: BlendMode,
1781    ) {
1782        if !stroke.is_visible() {
1783            return;
1784        }
1785        self.push_blended_primitive(
1786            DrawPrimitive::Rect {
1787                rect,
1788                brush,
1789                stroke: Some(stroke),
1790            },
1791            blend_mode,
1792        );
1793    }
1794
1795    fn draw_round_rect_stroked(&mut self, brush: Brush, radii: CornerRadii, stroke: Stroke) {
1796        self.draw_round_rect_stroked_blend(brush, radii, stroke, BlendMode::SrcOver);
1797    }
1798
1799    fn draw_round_rect_stroked_blend(
1800        &mut self,
1801        brush: Brush,
1802        radii: CornerRadii,
1803        stroke: Stroke,
1804        blend_mode: BlendMode,
1805    ) {
1806        self.draw_round_rect_at_stroked_blend(
1807            Rect::from_size(self.size),
1808            brush,
1809            radii,
1810            stroke,
1811            blend_mode,
1812        );
1813    }
1814
1815    fn draw_round_rect_at_stroked(
1816        &mut self,
1817        rect: Rect,
1818        brush: Brush,
1819        radii: CornerRadii,
1820        stroke: Stroke,
1821    ) {
1822        self.draw_round_rect_at_stroked_blend(rect, brush, radii, stroke, BlendMode::SrcOver);
1823    }
1824
1825    fn draw_round_rect_at_stroked_blend(
1826        &mut self,
1827        rect: Rect,
1828        brush: Brush,
1829        radii: CornerRadii,
1830        stroke: Stroke,
1831        blend_mode: BlendMode,
1832    ) {
1833        if !stroke.is_visible() {
1834            return;
1835        }
1836        self.push_blended_primitive(
1837            DrawPrimitive::RoundRect {
1838                rect,
1839                brush,
1840                radii,
1841                stroke: Some(stroke),
1842            },
1843            blend_mode,
1844        );
1845    }
1846
1847    fn draw_circle_stroked(&mut self, brush: Brush, center: Point, radius: f32, stroke: Stroke) {
1848        self.draw_circle_stroked_blend(brush, center, radius, stroke, BlendMode::SrcOver);
1849    }
1850
1851    fn draw_circle_stroked_blend(
1852        &mut self,
1853        brush: Brush,
1854        center: Point,
1855        radius: f32,
1856        stroke: Stroke,
1857        blend_mode: BlendMode,
1858    ) {
1859        if !stroke.is_visible() || !radius.is_finite() {
1860            return;
1861        }
1862        let radius = radius.max(0.0);
1863        let diameter = radius * 2.0;
1864        self.draw_round_rect_at_stroked_blend(
1865            Rect {
1866                x: center.x - radius,
1867                y: center.y - radius,
1868                width: diameter,
1869                height: diameter,
1870            },
1871            brush,
1872            CornerRadii::uniform(radius),
1873            stroke,
1874            blend_mode,
1875        );
1876    }
1877
1878    fn draw_arc(
1879        &mut self,
1880        brush: Brush,
1881        center: Point,
1882        radius: f32,
1883        start_angle: f32,
1884        sweep_angle: f32,
1885        stroke: Stroke,
1886    ) {
1887        self.draw_arc_blend(
1888            brush,
1889            center,
1890            radius,
1891            start_angle,
1892            sweep_angle,
1893            stroke,
1894            BlendMode::SrcOver,
1895        );
1896    }
1897
1898    fn draw_arc_blend(
1899        &mut self,
1900        brush: Brush,
1901        center: Point,
1902        radius: f32,
1903        start_angle: f32,
1904        sweep_angle: f32,
1905        stroke: Stroke,
1906        blend_mode: BlendMode,
1907    ) {
1908        if !stroke.is_visible() {
1909            return;
1910        }
1911        self.push_arc(
1912            brush,
1913            center,
1914            radius,
1915            start_angle,
1916            sweep_angle,
1917            Some(stroke),
1918            0.0,
1919            blend_mode,
1920        );
1921    }
1922
1923    fn draw_annular_sector(
1924        &mut self,
1925        brush: Brush,
1926        center: Point,
1927        inner_radius: f32,
1928        outer_radius: f32,
1929        start_angle: f32,
1930        sweep_angle: f32,
1931    ) {
1932        self.draw_annular_sector_blend(
1933            brush,
1934            center,
1935            inner_radius,
1936            outer_radius,
1937            start_angle,
1938            sweep_angle,
1939            BlendMode::SrcOver,
1940        );
1941    }
1942
1943    fn draw_annular_sector_blend(
1944        &mut self,
1945        brush: Brush,
1946        center: Point,
1947        inner_radius: f32,
1948        outer_radius: f32,
1949        start_angle: f32,
1950        sweep_angle: f32,
1951        blend_mode: BlendMode,
1952    ) {
1953        self.push_arc(
1954            brush,
1955            center,
1956            outer_radius,
1957            start_angle,
1958            sweep_angle,
1959            None,
1960            inner_radius,
1961            blend_mode,
1962        );
1963    }
1964
1965    fn draw_circle(&mut self, brush: Brush, center: Point, radius: f32) {
1966        self.draw_circle_blend(brush, center, radius, BlendMode::SrcOver);
1967    }
1968
1969    fn draw_circle_blend(
1970        &mut self,
1971        brush: Brush,
1972        center: Point,
1973        radius: f32,
1974        blend_mode: BlendMode,
1975    ) {
1976        let radius = radius.max(0.0);
1977        let diameter = radius * 2.0;
1978        self.push_blended_primitive(
1979            DrawPrimitive::RoundRect {
1980                rect: Rect {
1981                    x: center.x - radius,
1982                    y: center.y - radius,
1983                    width: diameter,
1984                    height: diameter,
1985                },
1986                brush,
1987                radii: CornerRadii::uniform(radius),
1988                stroke: None,
1989            },
1990            blend_mode,
1991        );
1992    }
1993
1994    fn draw_image(&mut self, image: ImageBitmap) {
1995        self.draw_image_blend(image, BlendMode::SrcOver);
1996    }
1997
1998    fn draw_image_blend(&mut self, image: ImageBitmap, blend_mode: BlendMode) {
1999        self.push_blended_primitive(
2000            DrawPrimitive::Image {
2001                rect: Rect::from_size(self.size),
2002                image,
2003                alpha: 1.0,
2004                color_filter: None,
2005                sampling: ImageSampling::Nearest,
2006                src_rect: None,
2007            },
2008            blend_mode,
2009        );
2010    }
2011
2012    fn draw_image_at(
2013        &mut self,
2014        rect: Rect,
2015        image: ImageBitmap,
2016        alpha: f32,
2017        color_filter: Option<ColorFilter>,
2018    ) {
2019        self.draw_image_at_sampled(rect, image, alpha, color_filter, ImageSampling::Nearest);
2020    }
2021
2022    fn draw_image_at_sampled(
2023        &mut self,
2024        rect: Rect,
2025        image: ImageBitmap,
2026        alpha: f32,
2027        color_filter: Option<ColorFilter>,
2028        sampling: ImageSampling,
2029    ) {
2030        self.push_blended_primitive(
2031            DrawPrimitive::Image {
2032                rect,
2033                image,
2034                alpha: alpha.clamp(0.0, 1.0),
2035                color_filter,
2036                sampling,
2037                src_rect: None,
2038            },
2039            BlendMode::SrcOver,
2040        );
2041    }
2042
2043    fn draw_image_at_blend(
2044        &mut self,
2045        rect: Rect,
2046        image: ImageBitmap,
2047        alpha: f32,
2048        color_filter: Option<ColorFilter>,
2049        blend_mode: BlendMode,
2050    ) {
2051        self.push_blended_primitive(
2052            DrawPrimitive::Image {
2053                rect,
2054                image,
2055                alpha: alpha.clamp(0.0, 1.0),
2056                color_filter,
2057                sampling: ImageSampling::Nearest,
2058                src_rect: None,
2059            },
2060            blend_mode,
2061        );
2062    }
2063
2064    fn draw_image_src(
2065        &mut self,
2066        image: ImageBitmap,
2067        src_rect: Rect,
2068        dst_rect: Rect,
2069        alpha: f32,
2070        color_filter: Option<ColorFilter>,
2071    ) {
2072        self.draw_image_src_blend(
2073            image,
2074            src_rect,
2075            dst_rect,
2076            alpha,
2077            color_filter,
2078            BlendMode::SrcOver,
2079        );
2080    }
2081
2082    fn draw_image_src_sampled(
2083        &mut self,
2084        image: ImageBitmap,
2085        src_rect: Rect,
2086        dst_rect: Rect,
2087        alpha: f32,
2088        color_filter: Option<ColorFilter>,
2089        sampling: ImageSampling,
2090    ) {
2091        self.push_blended_primitive(
2092            DrawPrimitive::Image {
2093                rect: dst_rect,
2094                image,
2095                alpha: alpha.clamp(0.0, 1.0),
2096                color_filter,
2097                sampling,
2098                src_rect: Some(src_rect),
2099            },
2100            BlendMode::SrcOver,
2101        );
2102    }
2103
2104    fn draw_image_src_blend(
2105        &mut self,
2106        image: ImageBitmap,
2107        src_rect: Rect,
2108        dst_rect: Rect,
2109        alpha: f32,
2110        color_filter: Option<ColorFilter>,
2111        blend_mode: BlendMode,
2112    ) {
2113        self.push_blended_primitive(
2114            DrawPrimitive::Image {
2115                rect: dst_rect,
2116                image,
2117                alpha: alpha.clamp(0.0, 1.0),
2118                color_filter,
2119                sampling: ImageSampling::Nearest,
2120                src_rect: Some(src_rect),
2121            },
2122            blend_mode,
2123        );
2124    }
2125
2126    fn draw_vector_path(&mut self, path: &crate::VectorPath, brush: Brush) {
2127        /// Rasterization supersampling factor relative to scope units.
2128        /// Combined with the rasterizer's own sub-scanline anti-aliasing
2129        /// and linear image sampling, this keeps icon edges crisp on
2130        /// high-density screens.
2131        const SUPERSAMPLE: f32 = 2.0;
2132        /// Safety cap for the rasterized mask dimensions.
2133        const MAX_MASK_PIXELS: f32 = 4096.0;
2134
2135        if path.is_empty() {
2136            return;
2137        }
2138        let bounds = path.bounds();
2139        if bounds.width <= 0.0 || bounds.height <= 0.0 {
2140            return;
2141        }
2142
2143        let color = match &brush {
2144            Brush::Solid(color) => *color,
2145            Brush::LinearGradient { colors, .. }
2146            | Brush::RadialGradient { colors, .. }
2147            | Brush::SweepGradient { colors, .. } => match colors.first() {
2148                Some(color) => *color,
2149                None => return,
2150            },
2151        };
2152        if color.3 <= 0.0 {
2153            return;
2154        }
2155
2156        // Rasterize a padded, integer-aligned bounding box so anti-aliased
2157        // edges are never clipped by the mask border.
2158        let origin = Point::new(bounds.x.floor() - 1.0, bounds.y.floor() - 1.0);
2159        let rect_width = (bounds.x + bounds.width).ceil() - origin.x + 1.0;
2160        let rect_height = (bounds.y + bounds.height).ceil() - origin.y + 1.0;
2161        let mask_width = (rect_width * SUPERSAMPLE)
2162            .ceil()
2163            .clamp(1.0, MAX_MASK_PIXELS) as usize;
2164        let mask_height = (rect_height * SUPERSAMPLE)
2165            .ceil()
2166            .clamp(1.0, MAX_MASK_PIXELS) as usize;
2167
2168        let red = (color.0.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
2169        let green = (color.1.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
2170        let blue = (color.2.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
2171        let alpha = color.3.clamp(0.0, 1.0);
2172        let key = vector_path_mask_key(
2173            path,
2174            origin,
2175            (mask_width, mask_height),
2176            [red, green, blue],
2177            alpha,
2178        );
2179        let cached = vector_path_mask_cache_get(key);
2180        let image = match cached {
2181            Some(image) => image,
2182            None => {
2183                let mask = path.coverage_mask(mask_width, mask_height, origin, SUPERSAMPLE);
2184                let mut pixels = Vec::with_capacity(mask.len() * 4);
2185                for coverage in mask {
2186                    pixels.extend_from_slice(&[
2187                        red,
2188                        green,
2189                        blue,
2190                        (alpha * coverage as f32 + 0.5) as u8,
2191                    ]);
2192                }
2193                let Ok(image) =
2194                    ImageBitmap::from_rgba8(mask_width as u32, mask_height as u32, pixels)
2195                else {
2196                    return;
2197                };
2198                vector_path_mask_cache_put(key, image.clone());
2199                image
2200            }
2201        };
2202
2203        self.push_other(DrawPrimitive::Image {
2204            rect: Rect {
2205                x: origin.x,
2206                y: origin.y,
2207                width: rect_width,
2208                height: rect_height,
2209            },
2210            image,
2211            alpha: 1.0,
2212            color_filter: None,
2213            sampling: ImageSampling::Linear,
2214            src_rect: None,
2215        });
2216    }
2217
2218    fn measure_text(&self, text: &str, style: &TextStyle) -> TextMeasurement {
2219        match &self.text_measurer {
2220            Some(measurer) => measurer.measure_text(text, style),
2221            None => estimate_text_measurement(text, style),
2222        }
2223    }
2224
2225    fn draw_text_at(&mut self, rect: Rect, brush: Brush, text: &str, style: &TextStyle) {
2226        // An empty run has no glyphs and a zero-area box, which every renderer
2227        // would drop anyway — stop here so it never reaches the scene.
2228        if text.is_empty() {
2229            return;
2230        }
2231        let Some(color) = solid_fill_color(&brush) else {
2232            return;
2233        };
2234        if color.3 <= 0.0 {
2235            return;
2236        }
2237        let measurement = self.measure_text(text, style);
2238        if !(measurement.size.width > 0.0 && measurement.size.height > 0.0) {
2239            return;
2240        }
2241        let origin = align_text_block(rect, measurement, style);
2242        if !origin.x.is_finite() || !origin.y.is_finite() {
2243            return;
2244        }
2245        self.push_other(DrawPrimitive::Text(Box::new(TextPrimitive {
2246            rect: Rect::from_origin_size(origin, measurement.size),
2247            text: shared_text_str(text),
2248            style: style.clone(),
2249            color,
2250        })));
2251    }
2252
2253    fn into_primitives(self) -> Vec<DrawPrimitive> {
2254        self.finish().primitives
2255    }
2256}
2257
2258/// The single color a brush paints with, or its first stop for a gradient.
2259///
2260/// Text is filled per glyph from one vertex color, so a gradient cannot be
2261/// honored; this mirrors the fallback [`DrawScope::draw_vector_path`] documents.
2262fn solid_fill_color(brush: &Brush) -> Option<Color> {
2263    match brush {
2264        Brush::Solid(color) => Some(*color),
2265        Brush::LinearGradient { colors, .. }
2266        | Brush::RadialGradient { colors, .. }
2267        | Brush::SweepGradient { colors, .. } => colors.first().copied(),
2268    }
2269}
2270
2271#[cfg(test)]
2272mod tests {
2273    use super::*;
2274    use crate::{Color, FontStyle, FontWeight, ImageBitmap, RenderEffect};
2275
2276    /// The compact recorder routes solid `SrcOver` shapes through typed
2277    /// records and everything else through ordinary primitives; the tape
2278    /// must reassemble the exact sequence recording used to produce
2279    /// directly, arc lowering and degeneracy drops included.
2280    #[test]
2281    fn compact_recording_materializes_in_recorded_order() {
2282        let size = Size::new(100.0, 100.0);
2283        let solid = Brush::solid(Color::WHITE);
2284        let gradient = Brush::vertical_gradient(vec![Color::RED, Color::BLUE], 0.0, 100.0);
2285        let center = Point::new(50.0, 50.0);
2286        let stroke = Stroke::new(4.0);
2287        let rect = Rect {
2288            x: 10.0,
2289            y: 20.0,
2290            width: 30.0,
2291            height: 40.0,
2292        };
2293        let batch = vec![
2294            DrawPrimitive::Content,
2295            DrawPrimitive::Rect {
2296                rect,
2297                brush: solid.clone(),
2298                stroke: None,
2299            },
2300        ];
2301
2302        // Interleave every routing path.
2303        let record = |scope: &mut DrawScopeDefault| {
2304            scope.draw_rect_at(rect, solid.clone());
2305            scope.draw_arc(solid.clone(), center, 30.0, 0.5, 1.5, stroke);
2306            scope.draw_rect_at(rect, gradient.clone());
2307            scope.draw_circle(solid.clone(), center, 12.0);
2308            scope.draw_arc(solid.clone(), center, 30.0, 0.5, 0.0, stroke); // degenerate: dropped
2309            scope.draw_rect_at_blend(rect, solid.clone(), BlendMode::Plus);
2310            scope.draw_content();
2311            scope.draw_annular_sector(gradient.clone(), center, 10.0, 20.0, 0.0, 2.0);
2312            scope.push_recorded(batch.clone());
2313        };
2314
2315        let mut compact = DrawScopeDefault::new(size);
2316        record(&mut compact);
2317        let finished = compact.finish();
2318
2319        // The expected sequence, built through the primitives the ordinary
2320        // lowering produces (the non-solid arc still takes that path, so it
2321        // serves as its own reference for the solid one's geometry).
2322        let arc_via_ordinary = |brush: Brush, radius: f32, start: f32, sweep: f32| {
2323            let mut scope = DrawScopeDefault::new(size);
2324            scope.draw_arc(brush, center, radius, start, sweep, stroke);
2325            scope.into_primitives().remove(0)
2326        };
2327        let expected = vec![
2328            DrawPrimitive::Rect {
2329                rect,
2330                brush: solid.clone(),
2331                stroke: None,
2332            },
2333            arc_via_ordinary(solid.clone(), 30.0, 0.5, 1.5),
2334            DrawPrimitive::Rect {
2335                rect,
2336                brush: gradient.clone(),
2337                stroke: None,
2338            },
2339            DrawPrimitive::RoundRect {
2340                rect: Rect {
2341                    x: center.x - 12.0,
2342                    y: center.y - 12.0,
2343                    width: 24.0,
2344                    height: 24.0,
2345                },
2346                brush: solid.clone(),
2347                radii: CornerRadii::uniform(12.0),
2348                stroke: None,
2349            },
2350            DrawPrimitive::Blend {
2351                primitive: Box::new(DrawPrimitive::Rect {
2352                    rect,
2353                    brush: solid.clone(),
2354                    stroke: None,
2355                }),
2356                blend_mode: BlendMode::Plus,
2357            },
2358            DrawPrimitive::Content,
2359            {
2360                let mut scope = DrawScopeDefault::new(size);
2361                scope.draw_annular_sector(gradient.clone(), center, 10.0, 20.0, 0.0, 2.0);
2362                scope.into_primitives().remove(0)
2363            },
2364            DrawPrimitive::Content,
2365            DrawPrimitive::Rect {
2366                rect,
2367                brush: solid.clone(),
2368                stroke: None,
2369            },
2370        ];
2371        assert_eq!(finished.primitives, expected);
2372        assert_eq!(finished.content_markers, 2);
2373    }
2374
2375    /// A recording that reuses another command's buffers (junk capacity in
2376    /// every store) must be byte-identical to one recorded fresh.
2377    #[test]
2378    fn reused_recording_buffers_record_identically_to_fresh() {
2379        let size = Size::new(64.0, 64.0);
2380        let record = |scope: &mut DrawScopeDefault| {
2381            scope.draw_circle(Brush::solid(Color::RED), Point::new(32.0, 32.0), 10.0);
2382            scope.draw_arc(
2383                Brush::solid(Color::BLUE),
2384                Point::new(32.0, 32.0),
2385                20.0,
2386                0.0,
2387                3.0,
2388                Stroke::new(2.0),
2389            );
2390        };
2391
2392        let mut fresh = DrawScopeDefault::new(size);
2393        record(&mut fresh);
2394        let fresh = fresh.finish();
2395
2396        // Dirty the buffers with an unrelated recording first.
2397        let mut dirty = DrawScopeDefault::new(size);
2398        dirty.draw_rect(Brush::solid(Color::BLACK));
2399        dirty.draw_content();
2400        dirty.draw_arc(
2401            Brush::solid(Color::WHITE),
2402            Point::new(1.0, 1.0),
2403            5.0,
2404            1.0,
2405            1.0,
2406            Stroke::new(1.0),
2407        );
2408        let dirty = dirty.finish();
2409
2410        let mut reused =
2411            DrawScopeDefault::with_recording(size, None, dirty.recording, dirty.primitives);
2412        record(&mut reused);
2413        let reused = reused.finish();
2414
2415        assert_eq!(fresh.primitives, reused.primitives);
2416        assert_eq!(fresh.content_markers, reused.content_markers);
2417    }
2418
2419    #[test]
2420    fn redrawing_the_same_text_shares_one_str_allocation() {
2421        let first = shared_text_str("BREAK THE RING");
2422        let second = shared_text_str("BREAK THE RING");
2423        assert!(Rc::ptr_eq(&first, &second));
2424        assert_eq!(&*second, "BREAK THE RING");
2425    }
2426
2427    #[test]
2428    fn different_text_gets_its_own_str() {
2429        let first = shared_text_str("340");
2430        let second = shared_text_str("350");
2431        assert!(!Rc::ptr_eq(&first, &second));
2432        assert_eq!(&*first, "340");
2433        assert_eq!(&*second, "350");
2434    }
2435
2436    #[test]
2437    fn the_text_pool_survives_overflowing_its_capacity() {
2438        for index in 0..600 {
2439            let text = format!("run-{index}");
2440            assert_eq!(&*shared_text_str(&text), text.as_str());
2441        }
2442        assert_eq!(&*shared_text_str("still correct"), "still correct");
2443    }
2444
2445    fn assert_image_alpha(primitive: &DrawPrimitive, expected: f32) {
2446        match primitive {
2447            DrawPrimitive::Image { alpha, .. } => assert!((alpha - expected).abs() < 1e-5),
2448            DrawPrimitive::Blend { primitive, .. } => assert_image_alpha(primitive, expected),
2449            other => panic!("expected image primitive, got {other:?}"),
2450        }
2451    }
2452
2453    fn unwrap_image(primitive: &DrawPrimitive) -> &DrawPrimitive {
2454        match primitive {
2455            DrawPrimitive::Image { .. } => primitive,
2456            DrawPrimitive::Blend { primitive, .. } => unwrap_image(primitive),
2457            other => panic!("expected image primitive, got {other:?}"),
2458        }
2459    }
2460
2461    #[test]
2462    fn draw_svg_path_emits_supersampled_image_over_path_bounds() {
2463        let mut scope = DrawScopeDefault::new(Size::new(32.0, 32.0));
2464        scope.draw_svg_path("M 4 4 H 20 V 20 H 4 Z", Brush::solid(Color::RED));
2465
2466        let primitives = scope.into_primitives();
2467        assert_eq!(primitives.len(), 1);
2468        let DrawPrimitive::Image { rect, image, .. } = &primitives[0] else {
2469            panic!("expected image primitive, got {:?}", primitives[0]);
2470        };
2471
2472        // Padded, integer-aligned bounds: (3,3) to (21,21).
2473        assert_eq!((rect.x, rect.y), (3.0, 3.0));
2474        assert_eq!((rect.width, rect.height), (18.0, 18.0));
2475        // Rasterized at 2x supersampling.
2476        assert_eq!((image.width(), image.height()), (36, 36));
2477
2478        // Probe the pixel at path point (12, 12): mask position
2479        // ((12 - 3) * 2, (12 - 3) * 2) = (18, 18) — fully covered red.
2480        let pixels = image.pixels();
2481        let index = (18 * 36 + 18) * 4;
2482        assert_eq!(
2483            &pixels[index..index + 4],
2484            &[255, 0, 0, 255],
2485            "path interior must be opaque brush color"
2486        );
2487        // A corner outside the square must be transparent.
2488        assert_eq!(pixels[3], 0, "outside the path must stay transparent");
2489    }
2490
2491    #[test]
2492    fn draw_svg_path_ignores_invalid_data() {
2493        let mut scope = DrawScopeDefault::new(Size::new(16.0, 16.0));
2494        scope.draw_svg_path("definitely not a path", Brush::solid(Color::WHITE));
2495        assert!(scope.into_primitives().is_empty());
2496    }
2497
2498    #[test]
2499    fn draw_vector_path_applies_brush_alpha() {
2500        let path = crate::VectorPath::parse("M 0 0 H 8 V 8 H 0 Z").expect("valid path");
2501        let mut scope = DrawScopeDefault::new(Size::new(16.0, 16.0));
2502        scope.draw_vector_path(&path, Brush::solid(Color::rgba(0.0, 0.0, 1.0, 0.5)));
2503
2504        let primitives = scope.into_primitives();
2505        let DrawPrimitive::Image { image, .. } = &primitives[0] else {
2506            panic!("expected image primitive");
2507        };
2508        let pixels = image.pixels();
2509        // Center of the mask: interior pixel with half-alpha blue.
2510        let width = image.width() as usize;
2511        let index = ((image.height() as usize / 2) * width + width / 2) * 4;
2512        assert_eq!(&pixels[index..index + 3], &[0, 0, 255]);
2513        let alpha = pixels[index + 3];
2514        assert!(
2515            (alpha as i32 - 128).abs() <= 2,
2516            "interior alpha must honor the brush alpha, got {alpha}"
2517        );
2518    }
2519
2520    #[test]
2521    fn the_same_path_and_color_reuse_one_raster() {
2522        let path = crate::VectorPath::parse("M 0 0 H 7 V 7 H 0 Z").expect("valid path");
2523        let raster_of = |brush: Brush| {
2524            let mut scope = DrawScopeDefault::new(Size::new(16.0, 16.0));
2525            scope.draw_vector_path(&path, brush);
2526            let primitives = scope.into_primitives();
2527            let DrawPrimitive::Image { image, .. } = &primitives[0] else {
2528                panic!("expected image primitive");
2529            };
2530            image.clone()
2531        };
2532
2533        let first = raster_of(Brush::solid(Color::rgba(0.0, 0.0, 1.0, 1.0)));
2534        let second = raster_of(Brush::solid(Color::rgba(0.0, 0.0, 1.0, 1.0)));
2535        assert_eq!(first.id(), second.id());
2536
2537        let other_color = raster_of(Brush::solid(Color::rgba(1.0, 0.0, 0.0, 1.0)));
2538        assert_ne!(first.id(), other_color.id());
2539
2540        let wider = crate::VectorPath::parse("M 0 0 H 9 V 7 H 0 Z").expect("valid path");
2541        let mut scope = DrawScopeDefault::new(Size::new(16.0, 16.0));
2542        scope.draw_vector_path(&wider, Brush::solid(Color::rgba(0.0, 0.0, 1.0, 1.0)));
2543        let primitives = scope.into_primitives();
2544        let DrawPrimitive::Image { image, .. } = &primitives[0] else {
2545            panic!("expected image primitive");
2546        };
2547        assert_ne!(first.id(), image.id());
2548    }
2549
2550    #[test]
2551    fn draw_content_inserts_content_marker() {
2552        let mut scope = DrawScopeDefault::new(Size::new(8.0, 8.0));
2553        scope.draw_rect(Brush::solid(Color::WHITE));
2554        scope.draw_content();
2555        scope.draw_rect_blend(Brush::solid(Color::BLACK), BlendMode::DstOut);
2556
2557        let primitives = scope.into_primitives();
2558        assert!(matches!(primitives[1], DrawPrimitive::Content));
2559        assert!(matches!(
2560            primitives[2],
2561            DrawPrimitive::Blend {
2562                blend_mode: BlendMode::DstOut,
2563                ..
2564            }
2565        ));
2566    }
2567
2568    /// The retained-recording path exists so a command that re-records every
2569    /// frame keeps the buffers it already grew. Handing storage in and getting
2570    /// it back has to record exactly what a fresh scope would, and the count of
2571    /// content markers has to come back without re-scanning the primitives.
2572    #[test]
2573    fn a_reused_recording_buffer_records_what_a_fresh_one_would() {
2574        let size = Size::new(16.0, 16.0);
2575        let measurer: Rc<dyn DrawTextMeasurer> = FixedAdvanceTextMeasurer::shared(10.0, 20.0);
2576
2577        let draw = |scope: &mut DrawScopeDefault| {
2578            scope.draw_rect(Brush::solid(Color::WHITE));
2579            scope.draw_content();
2580            scope.draw_rect(Brush::solid(Color::BLACK));
2581            scope.draw_content();
2582        };
2583
2584        let mut fresh = DrawScopeDefault::with_text_measurer(size, Rc::clone(&measurer));
2585        draw(&mut fresh);
2586        assert_eq!(fresh.content_marker_count(), 2);
2587        let expected = fresh.finish();
2588
2589        // Storage the caller already owns, carrying capacity from an earlier
2590        // frame. What comes out has to be the same recording.
2591        let storage = Vec::with_capacity(64);
2592        let mut reused = DrawScopeDefault::with_text_measurer_reusing(size, measurer, storage);
2593        draw(&mut reused);
2594        assert_eq!(reused.content_marker_count(), 2);
2595        let reused = reused.finish();
2596
2597        assert_eq!(reused.primitives.len(), expected.primitives.len());
2598        assert_eq!(reused.content_markers, expected.content_markers);
2599        assert_eq!(reused.dropped, expected.dropped);
2600    }
2601
2602    /// A frame that is about to serve a previous frame's primitives should not
2603    /// pay to build this frame's. Finishing recording-only returns the buffers
2604    /// cleared, and the marker count, without materializing anything.
2605    #[test]
2606    fn finishing_recording_only_materializes_nothing_but_still_reports_markers() {
2607        let mut scope = DrawScopeDefault::new(Size::new(8.0, 8.0));
2608        scope.draw_rect(Brush::solid(Color::WHITE));
2609        scope.draw_content();
2610        assert_eq!(scope.content_marker_count(), 1);
2611
2612        let finished = scope.finish_recording_only();
2613        assert!(
2614            finished.primitives.is_empty(),
2615            "nothing should have been materialized"
2616        );
2617        assert_eq!(finished.content_markers, 1);
2618        assert!(finished.dropped.is_empty());
2619        assert_eq!(
2620            finished.recording.tape.len(),
2621            0,
2622            "the recording buffer comes back cleared, ready to be recorded into again"
2623        );
2624    }
2625
2626    /// Where a block of text lands inside the rect it was given. Split out of
2627    /// the draw so the rule is stated once; a wrong vertical rule is what makes
2628    /// baseline-aligned text sit a line too low.
2629    #[test]
2630    fn a_text_block_is_placed_by_its_alignment_inside_the_rect() {
2631        let rect = Rect {
2632            x: 10.0,
2633            y: 20.0,
2634            width: 100.0,
2635            height: 40.0,
2636        };
2637        let measurement = TextMeasurement {
2638            size: Size::new(60.0, 16.0),
2639            line_height: 16.0,
2640            first_baseline: 12.0,
2641            line_count: 1,
2642        };
2643        let style = |align, vertical| {
2644            TextStyle::default()
2645                .with_align(align)
2646                .with_vertical_align(vertical)
2647        };
2648
2649        let left = align_text_block(
2650            rect,
2651            measurement,
2652            &style(TextAlign::Left, TextVerticalAlign::Top),
2653        );
2654        assert_eq!(left, Point::new(10.0, 20.0));
2655
2656        let centered = align_text_block(
2657            rect,
2658            measurement,
2659            &style(TextAlign::Center, TextVerticalAlign::Center),
2660        );
2661        assert_eq!(centered, Point::new(10.0 + 20.0, 20.0 + 12.0));
2662
2663        let right = align_text_block(
2664            rect,
2665            measurement,
2666            &style(TextAlign::Right, TextVerticalAlign::Bottom),
2667        );
2668        assert_eq!(right, Point::new(50.0, 44.0));
2669
2670        // Baseline alignment places the baseline on the rect's top edge, which
2671        // is what lets a caller line text up with something else.
2672        let baseline = align_text_block(
2673            rect,
2674            measurement,
2675            &style(TextAlign::Left, TextVerticalAlign::Baseline),
2676        );
2677        assert_eq!(baseline, Point::new(10.0, 20.0 - 12.0));
2678    }
2679
2680    #[test]
2681    fn draw_rect_blend_wraps_non_default_modes() {
2682        let mut scope = DrawScopeDefault::new(Size::new(10.0, 10.0));
2683        scope.draw_rect_blend(Brush::solid(Color::RED), BlendMode::DstOut);
2684
2685        let primitives = scope.into_primitives();
2686        assert_eq!(primitives.len(), 1);
2687        match &primitives[0] {
2688            DrawPrimitive::Blend {
2689                primitive,
2690                blend_mode,
2691            } => {
2692                assert_eq!(*blend_mode, BlendMode::DstOut);
2693                assert!(matches!(**primitive, DrawPrimitive::Rect { .. }));
2694            }
2695            other => panic!("expected blended primitive, got {other:?}"),
2696        }
2697    }
2698
2699    #[test]
2700    fn draw_circle_records_centered_round_rect() {
2701        let mut scope = DrawScopeDefault::new(Size::new(40.0, 40.0));
2702        scope.draw_circle(Brush::solid(Color::BLUE), Point::new(12.0, 16.0), 5.0);
2703
2704        let primitives = scope.into_primitives();
2705        assert_eq!(primitives.len(), 1);
2706        match &primitives[0] {
2707            DrawPrimitive::RoundRect { rect, radii, .. } => {
2708                assert_eq!(
2709                    *rect,
2710                    Rect {
2711                        x: 7.0,
2712                        y: 11.0,
2713                        width: 10.0,
2714                        height: 10.0,
2715                    }
2716                );
2717                assert_eq!(*radii, CornerRadii::uniform(5.0));
2718            }
2719            other => panic!("expected circular round rect, got {other:?}"),
2720        }
2721    }
2722
2723    #[test]
2724    fn draw_circle_blend_wraps_non_default_modes() {
2725        let mut scope = DrawScopeDefault::new(Size::new(10.0, 10.0));
2726        scope.draw_circle_blend(
2727            Brush::solid(Color::RED),
2728            Point::new(5.0, 5.0),
2729            3.0,
2730            BlendMode::Plus,
2731        );
2732
2733        let primitives = scope.into_primitives();
2734        assert_eq!(primitives.len(), 1);
2735        match &primitives[0] {
2736            DrawPrimitive::Blend {
2737                primitive,
2738                blend_mode,
2739            } => {
2740                assert_eq!(*blend_mode, BlendMode::Plus);
2741                assert!(matches!(**primitive, DrawPrimitive::RoundRect { .. }));
2742            }
2743            other => panic!("expected blended circle primitive, got {other:?}"),
2744        }
2745    }
2746
2747    #[test]
2748    fn rect_union_encloses_both_inputs() {
2749        let lhs = Rect {
2750            x: 10.0,
2751            y: 5.0,
2752            width: 8.0,
2753            height: 4.0,
2754        };
2755        let rhs = Rect {
2756            x: 4.0,
2757            y: 7.0,
2758            width: 10.0,
2759            height: 6.0,
2760        };
2761
2762        assert_eq!(
2763            lhs.union(rhs),
2764            Rect {
2765                x: 4.0,
2766                y: 5.0,
2767                width: 14.0,
2768                height: 8.0,
2769            }
2770        );
2771    }
2772
2773    #[test]
2774    fn draw_image_uses_scope_size_as_default_rect() {
2775        let mut scope = DrawScopeDefault::new(Size::new(40.0, 24.0));
2776        let image = ImageBitmap::from_rgba8(2, 2, vec![255; 16]).expect("image");
2777        scope.draw_image(image.clone());
2778        let primitives = scope.into_primitives();
2779        assert_eq!(primitives.len(), 1);
2780        match unwrap_image(&primitives[0]) {
2781            DrawPrimitive::Image {
2782                rect,
2783                image: actual,
2784                alpha,
2785                color_filter,
2786                sampling,
2787                src_rect,
2788            } => {
2789                assert_eq!(*rect, Rect::from_size(Size::new(40.0, 24.0)));
2790                assert_eq!(*actual, image);
2791                assert_eq!(*alpha, 1.0);
2792                assert!(color_filter.is_none());
2793                assert_eq!(*sampling, ImageSampling::Nearest);
2794                assert!(src_rect.is_none());
2795            }
2796            other => panic!("expected image primitive, got {other:?}"),
2797        }
2798    }
2799
2800    #[test]
2801    fn draw_image_src_stores_src_rect() {
2802        let mut scope = DrawScopeDefault::new(Size::new(100.0, 100.0));
2803        let image = ImageBitmap::from_rgba8(64, 64, vec![255; 64 * 64 * 4]).expect("image");
2804        let src = Rect {
2805            x: 10.0,
2806            y: 20.0,
2807            width: 30.0,
2808            height: 40.0,
2809        };
2810        let dst = Rect {
2811            x: 0.0,
2812            y: 0.0,
2813            width: 60.0,
2814            height: 80.0,
2815        };
2816        scope.draw_image_src(image.clone(), src, dst, 0.8, None);
2817        let primitives = scope.into_primitives();
2818        assert_eq!(primitives.len(), 1);
2819        match unwrap_image(&primitives[0]) {
2820            DrawPrimitive::Image {
2821                rect,
2822                image: actual,
2823                alpha,
2824                sampling,
2825                src_rect,
2826                ..
2827            } => {
2828                assert_eq!(*rect, dst);
2829                assert_eq!(*actual, image);
2830                assert!((alpha - 0.8).abs() < 1e-5);
2831                assert_eq!(*sampling, ImageSampling::Nearest);
2832                assert_eq!(*src_rect, Some(src));
2833            }
2834            other => panic!("expected image primitive, got {other:?}"),
2835        }
2836    }
2837
2838    #[test]
2839    fn draw_image_at_sampled_records_requested_sampling() {
2840        let mut scope = DrawScopeDefault::new(Size::new(100.0, 100.0));
2841        let image = ImageBitmap::from_rgba8(8, 8, vec![255; 8 * 8 * 4]).expect("image");
2842        let dst = Rect {
2843            x: 2.0,
2844            y: 3.0,
2845            width: 40.0,
2846            height: 30.0,
2847        };
2848
2849        scope.draw_image_at_sampled(dst, image.clone(), 0.7, None, ImageSampling::Linear);
2850
2851        let primitives = scope.into_primitives();
2852        assert_eq!(primitives.len(), 1);
2853        match unwrap_image(&primitives[0]) {
2854            DrawPrimitive::Image {
2855                rect,
2856                image: actual,
2857                alpha,
2858                sampling,
2859                src_rect,
2860                ..
2861            } => {
2862                assert_eq!(*rect, dst);
2863                assert_eq!(*actual, image);
2864                assert!((alpha - 0.7).abs() < 1e-5);
2865                assert_eq!(*sampling, ImageSampling::Linear);
2866                assert!(src_rect.is_none());
2867            }
2868            other => panic!("expected image primitive, got {other:?}"),
2869        }
2870    }
2871
2872    #[test]
2873    fn draw_image_src_sampled_records_requested_sampling() {
2874        let mut scope = DrawScopeDefault::new(Size::new(100.0, 100.0));
2875        let image = ImageBitmap::from_rgba8(64, 64, vec![255; 64 * 64 * 4]).expect("image");
2876        let src = Rect {
2877            x: 4.0,
2878            y: 6.0,
2879            width: 16.0,
2880            height: 20.0,
2881        };
2882        let dst = Rect {
2883            x: 8.0,
2884            y: 10.0,
2885            width: 32.0,
2886            height: 40.0,
2887        };
2888
2889        scope.draw_image_src_sampled(image.clone(), src, dst, 0.5, None, ImageSampling::Linear);
2890
2891        let primitives = scope.into_primitives();
2892        assert_eq!(primitives.len(), 1);
2893        match unwrap_image(&primitives[0]) {
2894            DrawPrimitive::Image {
2895                rect,
2896                image: actual,
2897                alpha,
2898                sampling,
2899                src_rect,
2900                ..
2901            } => {
2902                assert_eq!(*rect, dst);
2903                assert_eq!(*actual, image);
2904                assert!((alpha - 0.5).abs() < 1e-5);
2905                assert_eq!(*sampling, ImageSampling::Linear);
2906                assert_eq!(*src_rect, Some(src));
2907            }
2908            other => panic!("expected image primitive, got {other:?}"),
2909        }
2910    }
2911
2912    #[test]
2913    fn draw_image_at_clamps_alpha() {
2914        let mut scope = DrawScopeDefault::new(Size::new(10.0, 10.0));
2915        let image = ImageBitmap::from_rgba8(1, 1, vec![255, 255, 255, 255]).expect("image");
2916        scope.draw_image_at(
2917            Rect::from_origin_size(Point::new(2.0, 3.0), Size::new(5.0, 6.0)),
2918            image,
2919            3.0,
2920            Some(ColorFilter::Tint(Color::from_rgba_u8(128, 128, 255, 255))),
2921        );
2922        assert_image_alpha(&scope.into_primitives()[0], 1.0);
2923    }
2924
2925    #[test]
2926    fn graphics_layer_clone_with_render_effect() {
2927        let layer = GraphicsLayer {
2928            render_effect: Some(RenderEffect::blur(10.0)),
2929            backdrop_effect: Some(RenderEffect::blur(6.0)),
2930            color_filter: Some(ColorFilter::tint(Color::from_rgba_u8(128, 200, 255, 255))),
2931            alpha: 0.5,
2932            rotation_z: 12.0,
2933            shadow_elevation: 4.0,
2934            shape: LayerShape::Rounded(RoundedCornerShape::uniform(6.0)),
2935            clip: true,
2936            compositing_strategy: CompositingStrategy::Offscreen,
2937            blend_mode: BlendMode::SrcOver,
2938            ..Default::default()
2939        };
2940        let cloned = layer.clone();
2941        assert_eq!(cloned.alpha, 0.5);
2942        assert!(cloned.render_effect.is_some());
2943        assert!(cloned.backdrop_effect.is_some());
2944        assert_eq!(layer.color_filter, cloned.color_filter);
2945        assert_eq!(layer.render_effect, cloned.render_effect);
2946        assert_eq!(layer.backdrop_effect, cloned.backdrop_effect);
2947        assert!((cloned.rotation_z - 12.0).abs() < 1e-6);
2948        assert!((cloned.shadow_elevation - 4.0).abs() < 1e-6);
2949        assert_eq!(
2950            cloned.shape,
2951            LayerShape::Rounded(RoundedCornerShape::uniform(6.0))
2952        );
2953        assert!(cloned.clip);
2954        assert_eq!(cloned.compositing_strategy, CompositingStrategy::Offscreen);
2955        assert_eq!(cloned.blend_mode, BlendMode::SrcOver);
2956    }
2957
2958    #[test]
2959    fn graphics_layer_default_has_no_effect() {
2960        let layer = GraphicsLayer::default();
2961        assert!(layer.color_filter.is_none());
2962        assert!(layer.render_effect.is_none());
2963        assert!(layer.backdrop_effect.is_none());
2964        assert_eq!(layer.compositing_strategy, CompositingStrategy::Auto);
2965        assert_eq!(layer.blend_mode, BlendMode::SrcOver);
2966        assert_eq!(layer.alpha, 1.0);
2967        assert_eq!(layer.transform_origin, TransformOrigin::CENTER);
2968        assert!((layer.camera_distance - 8.0).abs() < 1e-6);
2969        assert_eq!(layer.shape, LayerShape::Rectangle);
2970        assert!(!layer.clip);
2971        assert_eq!(layer.ambient_shadow_color, Color::BLACK);
2972        assert_eq!(layer.spot_shadow_color, Color::BLACK);
2973    }
2974
2975    #[test]
2976    fn transform_origin_construction() {
2977        let origin = TransformOrigin::new(0.25, 0.75);
2978        assert!((origin.pivot_fraction_x - 0.25).abs() < 1e-6);
2979        assert!((origin.pivot_fraction_y - 0.75).abs() < 1e-6);
2980    }
2981
2982    #[test]
2983    fn layer_shape_default_is_rectangle() {
2984        assert_eq!(LayerShape::default(), LayerShape::Rectangle);
2985    }
2986
2987    // ── Stroke / arc lowering ───────────────────────────────────────────────
2988
2989    use std::f32::consts::{FRAC_PI_2, PI};
2990
2991    use crate::{StrokeCap, StrokeJoin};
2992
2993    /// Arc bounds are conservative-approximate (fast endpoint trig plus a
2994    /// containment pad, see `stroke.rs`); geometry tests compare within that
2995    /// documented slack. The strict containment guard lives in the stroke
2996    /// module's property test.
2997    fn approx(a: f32, b: f32) -> bool {
2998        (a - b).abs() < 0.25
2999    }
3000
3001    fn scope(size: f32) -> DrawScopeDefault {
3002        DrawScopeDefault::new(Size::new(size, size))
3003    }
3004
3005    #[test]
3006    fn draw_rect_stroked_records_scope_rect_and_stroke() {
3007        let mut scope = scope(20.0);
3008        scope.draw_rect_stroked(
3009            Brush::solid(Color::RED),
3010            Stroke::new(3.0).with_join(StrokeJoin::Bevel),
3011        );
3012
3013        let primitives = scope.into_primitives();
3014        assert_eq!(primitives.len(), 1);
3015        match &primitives[0] {
3016            DrawPrimitive::Rect {
3017                rect,
3018                stroke: Some(stroke),
3019                ..
3020            } => {
3021                // The stored rect stays the *geometric* rect; the renderer
3022                // inflates it by half the stroke width when it builds the quad.
3023                assert_eq!(*rect, Rect::from_size(Size::new(20.0, 20.0)));
3024                assert_eq!(stroke.width, 3.0);
3025                assert_eq!(stroke.join, StrokeJoin::Bevel);
3026            }
3027            other => panic!("expected stroked rect, got {other:?}"),
3028        }
3029    }
3030
3031    #[test]
3032    fn draw_rect_at_stroked_records_requested_rect() {
3033        let mut scope = scope(50.0);
3034        let rect = Rect {
3035            x: 4.0,
3036            y: 6.0,
3037            width: 12.0,
3038            height: 9.0,
3039        };
3040        scope.draw_rect_at_stroked(rect, Brush::solid(Color::BLUE), Stroke::new(2.0));
3041        match &scope.into_primitives()[0] {
3042            DrawPrimitive::Rect {
3043                rect: actual,
3044                stroke: Some(stroke),
3045                ..
3046            } => {
3047                assert_eq!(*actual, rect);
3048                assert_eq!(stroke.width, 2.0);
3049            }
3050            other => panic!("expected stroked rect, got {other:?}"),
3051        }
3052    }
3053
3054    #[test]
3055    fn draw_round_rect_stroked_keeps_radii_and_stroke() {
3056        let mut scope = scope(30.0);
3057        scope.draw_round_rect_stroked(
3058            Brush::solid(Color::GREEN),
3059            CornerRadii::uniform(5.0),
3060            Stroke::new(4.0).with_join(StrokeJoin::Round),
3061        );
3062        match &scope.into_primitives()[0] {
3063            DrawPrimitive::RoundRect {
3064                rect,
3065                radii,
3066                stroke: Some(stroke),
3067                ..
3068            } => {
3069                assert_eq!(*rect, Rect::from_size(Size::new(30.0, 30.0)));
3070                assert_eq!(*radii, CornerRadii::uniform(5.0));
3071                assert_eq!(stroke.width, 4.0);
3072                assert_eq!(stroke.join, StrokeJoin::Round);
3073            }
3074            other => panic!("expected stroked round rect, got {other:?}"),
3075        }
3076    }
3077
3078    #[test]
3079    fn draw_round_rect_at_stroked_records_requested_rect() {
3080        let mut scope = scope(60.0);
3081        let rect = Rect {
3082            x: 1.0,
3083            y: 2.0,
3084            width: 20.0,
3085            height: 10.0,
3086        };
3087        scope.draw_round_rect_at_stroked(
3088            rect,
3089            Brush::solid(Color::WHITE),
3090            CornerRadii::uniform(3.0),
3091            Stroke::new(1.5),
3092        );
3093        match &scope.into_primitives()[0] {
3094            DrawPrimitive::RoundRect {
3095                rect: actual,
3096                radii,
3097                stroke: Some(stroke),
3098                ..
3099            } => {
3100                assert_eq!(*actual, rect);
3101                assert_eq!(*radii, CornerRadii::uniform(3.0));
3102                assert_eq!(stroke.width, 1.5);
3103            }
3104            other => panic!("expected stroked round rect, got {other:?}"),
3105        }
3106    }
3107
3108    #[test]
3109    fn draw_circle_stroked_lowers_to_stroked_round_rect() {
3110        // A stroked circle must reuse the round-rect path so it shares the
3111        // shape pipeline (and therefore the batch) with every other shape.
3112        let mut scope = scope(40.0);
3113        scope.draw_circle_stroked(
3114            Brush::solid(Color::BLUE),
3115            Point::new(12.0, 16.0),
3116            5.0,
3117            Stroke::new(2.0),
3118        );
3119        match &scope.into_primitives()[0] {
3120            DrawPrimitive::RoundRect {
3121                rect,
3122                radii,
3123                stroke: Some(stroke),
3124                ..
3125            } => {
3126                assert_eq!(
3127                    *rect,
3128                    Rect {
3129                        x: 7.0,
3130                        y: 11.0,
3131                        width: 10.0,
3132                        height: 10.0,
3133                    }
3134                );
3135                assert_eq!(*radii, CornerRadii::uniform(5.0));
3136                assert_eq!(stroke.width, 2.0);
3137            }
3138            other => panic!("expected stroked circular round rect, got {other:?}"),
3139        }
3140    }
3141
3142    #[test]
3143    fn draw_arc_records_arc_primitive_with_tight_bounds() {
3144        let mut scope = scope(200.0);
3145        scope.draw_arc(
3146            Brush::solid(Color::RED),
3147            Point::new(100.0, 100.0),
3148            50.0,
3149            0.0,
3150            FRAC_PI_2,
3151            Stroke::new(10.0),
3152        );
3153        let primitives = scope.into_primitives();
3154        assert_eq!(primitives.len(), 1);
3155        match &primitives[0] {
3156            DrawPrimitive::Arc {
3157                rect,
3158                center,
3159                radius,
3160                start_angle,
3161                sweep_angle,
3162                stroke: Some(stroke),
3163                inner_radius,
3164                ..
3165            } => {
3166                assert_eq!(*center, Point::new(100.0, 100.0));
3167                assert_eq!(*radius, 50.0);
3168                assert_eq!(*start_angle, 0.0);
3169                assert!(approx(*sweep_angle, FRAC_PI_2));
3170                assert_eq!(stroke.width, 10.0);
3171                assert_eq!(*inner_radius, 0.0);
3172                // Band is 45..55; a 0..90 degree sweep with butt caps spans
3173                // x = 100..155 and y = 100..155.
3174                assert!(approx(rect.x, 100.0), "{rect:?}");
3175                assert!(approx(rect.y, 100.0), "{rect:?}");
3176                assert!(approx(rect.width, 55.0), "{rect:?}");
3177                assert!(approx(rect.height, 55.0), "{rect:?}");
3178            }
3179            other => panic!("expected arc primitive, got {other:?}"),
3180        }
3181    }
3182
3183    #[test]
3184    fn draw_arc_bounds_cover_a_quadrant_spanning_sweep() {
3185        let mut scope = scope(200.0);
3186        // 0 -> 270 degrees: the bounds must be the full outer circle, not the
3187        // chord between the two endpoints.
3188        scope.draw_arc(
3189            Brush::solid(Color::RED),
3190            Point::new(100.0, 100.0),
3191            50.0,
3192            0.0,
3193            3.0 * FRAC_PI_2,
3194            Stroke::new(4.0),
3195        );
3196        let DrawPrimitive::Arc { rect, .. } = &scope.into_primitives()[0] else {
3197            panic!("expected arc primitive");
3198        };
3199        assert!(approx(rect.x, 48.0), "{rect:?}");
3200        assert!(approx(rect.y, 48.0), "{rect:?}");
3201        assert!(approx(rect.width, 104.0), "{rect:?}");
3202        assert!(approx(rect.height, 104.0), "{rect:?}");
3203    }
3204
3205    #[test]
3206    fn draw_annular_sector_records_inner_radius_and_no_stroke() {
3207        let mut scope = scope(200.0);
3208        scope.draw_annular_sector(
3209            Brush::solid(Color::WHITE),
3210            Point::new(100.0, 100.0),
3211            30.0,
3212            50.0,
3213            0.0,
3214            PI,
3215        );
3216        match &scope.into_primitives()[0] {
3217            DrawPrimitive::Arc {
3218                rect,
3219                center,
3220                radius,
3221                inner_radius,
3222                stroke,
3223                sweep_angle,
3224                ..
3225            } => {
3226                assert!(stroke.is_none(), "annular sectors are filled, not stroked");
3227                assert_eq!(*center, Point::new(100.0, 100.0));
3228                assert_eq!(*radius, 50.0);
3229                assert_eq!(*inner_radius, 30.0);
3230                assert!(approx(*sweep_angle, PI));
3231                // 0 -> 180 degrees: x spans -50..+50, y spans 0..+50.
3232                assert!(approx(rect.x, 50.0), "{rect:?}");
3233                assert!(approx(rect.y, 100.0), "{rect:?}");
3234                assert!(approx(rect.width, 100.0), "{rect:?}");
3235                assert!(approx(rect.height, 50.0), "{rect:?}");
3236            }
3237            other => panic!("expected arc primitive, got {other:?}"),
3238        }
3239    }
3240
3241    #[test]
3242    fn draw_arc_blend_wraps_non_default_modes() {
3243        let mut scope = scope(100.0);
3244        scope.draw_arc_blend(
3245            Brush::solid(Color::RED),
3246            Point::new(50.0, 50.0),
3247            20.0,
3248            0.0,
3249            1.0,
3250            Stroke::new(2.0),
3251            BlendMode::DstOut,
3252        );
3253        match &scope.into_primitives()[0] {
3254            DrawPrimitive::Blend {
3255                primitive,
3256                blend_mode,
3257            } => {
3258                assert_eq!(*blend_mode, BlendMode::DstOut);
3259                assert!(matches!(**primitive, DrawPrimitive::Arc { .. }));
3260            }
3261            other => panic!("expected blended arc, got {other:?}"),
3262        }
3263    }
3264
3265    #[test]
3266    fn draw_annular_sector_blend_wraps_non_default_modes() {
3267        let mut scope = scope(100.0);
3268        scope.draw_annular_sector_blend(
3269            Brush::solid(Color::RED),
3270            Point::new(50.0, 50.0),
3271            5.0,
3272            20.0,
3273            0.0,
3274            1.0,
3275            BlendMode::Plus,
3276        );
3277        assert!(matches!(
3278            &scope.into_primitives()[0],
3279            DrawPrimitive::Blend {
3280                blend_mode: BlendMode::Plus,
3281                ..
3282            }
3283        ));
3284    }
3285
3286    #[test]
3287    fn stroked_blend_variants_wrap_non_default_modes() {
3288        let mut scope = scope(20.0);
3289        scope.draw_rect_stroked_blend(
3290            Brush::solid(Color::RED),
3291            Stroke::new(2.0),
3292            BlendMode::DstOut,
3293        );
3294        scope.draw_round_rect_stroked_blend(
3295            Brush::solid(Color::RED),
3296            CornerRadii::uniform(2.0),
3297            Stroke::new(2.0),
3298            BlendMode::DstOut,
3299        );
3300        scope.draw_circle_stroked_blend(
3301            Brush::solid(Color::RED),
3302            Point::new(10.0, 10.0),
3303            5.0,
3304            Stroke::new(2.0),
3305            BlendMode::DstOut,
3306        );
3307        let primitives = scope.into_primitives();
3308        assert_eq!(primitives.len(), 3);
3309        for primitive in &primitives {
3310            assert!(
3311                matches!(
3312                    primitive,
3313                    DrawPrimitive::Blend {
3314                        blend_mode: BlendMode::DstOut,
3315                        ..
3316                    }
3317                ),
3318                "expected blended primitive, got {primitive:?}"
3319            );
3320        }
3321    }
3322
3323    #[test]
3324    fn negative_sweeps_and_overlong_sweeps_produce_finite_bounds() {
3325        let mut scope = scope(200.0);
3326        scope.draw_arc(
3327            Brush::solid(Color::RED),
3328            Point::new(100.0, 100.0),
3329            40.0,
3330            FRAC_PI_2,
3331            -FRAC_PI_2,
3332            Stroke::new(4.0),
3333        );
3334        scope.draw_arc(
3335            Brush::solid(Color::RED),
3336            Point::new(100.0, 100.0),
3337            40.0,
3338            0.3,
3339            crate::stroke::TAU * 4.0,
3340            Stroke::new(4.0),
3341        );
3342        let primitives = scope.into_primitives();
3343        assert_eq!(primitives.len(), 2);
3344
3345        let DrawPrimitive::Arc { rect: negative, .. } = &primitives[0] else {
3346            panic!("expected arc");
3347        };
3348        // 0 -> 90 degrees clockwise, band 38..42.
3349        assert!(approx(negative.x, 100.0), "{negative:?}");
3350        assert!(approx(negative.y, 100.0), "{negative:?}");
3351        assert!(approx(negative.width, 42.0), "{negative:?}");
3352
3353        let DrawPrimitive::Arc { rect: full, .. } = &primitives[1] else {
3354            panic!("expected arc");
3355        };
3356        // Anything past a full turn is a closed ring: the whole outer circle.
3357        assert!(approx(full.x, 58.0), "{full:?}");
3358        assert!(approx(full.width, 84.0), "{full:?}");
3359        assert!(approx(full.height, 84.0), "{full:?}");
3360    }
3361
3362    #[test]
3363    fn degenerate_stroke_and_arc_inputs_emit_nothing_and_never_panic() {
3364        let mut scope = scope(50.0);
3365        let brush = Brush::solid(Color::RED);
3366        let center = Point::new(25.0, 25.0);
3367
3368        // Zero / negative / non-finite stroke widths.
3369        scope.draw_rect_stroked(brush.clone(), Stroke::new(0.0));
3370        scope.draw_rect_stroked(brush.clone(), Stroke::new(-4.0));
3371        scope.draw_rect_stroked(brush.clone(), Stroke::new(f32::NAN));
3372        scope.draw_round_rect_stroked(brush.clone(), CornerRadii::uniform(2.0), Stroke::new(0.0));
3373        scope.draw_circle_stroked(brush.clone(), center, 10.0, Stroke::new(0.0));
3374        scope.draw_circle_stroked(brush.clone(), center, f32::NAN, Stroke::new(2.0));
3375        // Zero and non-finite sweeps.
3376        scope.draw_arc(brush.clone(), center, 10.0, 0.0, 0.0, Stroke::new(2.0));
3377        scope.draw_arc(brush.clone(), center, 10.0, 0.0, f32::NAN, Stroke::new(2.0));
3378        scope.draw_arc(
3379            brush.clone(),
3380            center,
3381            f32::INFINITY,
3382            0.0,
3383            1.0,
3384            Stroke::new(2.0),
3385        );
3386        // Zero-width arc stroke and zero radius with zero width.
3387        scope.draw_arc(brush.clone(), center, 10.0, 0.0, 1.0, Stroke::new(0.0));
3388        scope.draw_arc(brush.clone(), center, 0.0, 0.0, 1.0, Stroke::new(0.0));
3389        // Annular sectors with an empty band.
3390        scope.draw_annular_sector(brush.clone(), center, 10.0, 10.0, 0.0, 1.0);
3391        scope.draw_annular_sector(brush.clone(), center, 20.0, 10.0, 0.0, 1.0);
3392        scope.draw_annular_sector(brush.clone(), center, 0.0, 0.0, 0.0, 1.0);
3393        scope.draw_annular_sector(brush.clone(), center, 0.0, 10.0, 0.0, 0.0);
3394        scope.draw_annular_sector(brush, center, f32::NAN, 10.0, 0.0, 1.0);
3395
3396        assert!(
3397            scope.into_primitives().is_empty(),
3398            "degenerate stroke/arc requests must not reach the renderer"
3399        );
3400    }
3401
3402    #[test]
3403    fn zero_radius_arc_with_positive_width_stays_finite() {
3404        // radius 0 with a fat stroke is a filled wedge of radius width/2 —
3405        // legal, and it must not produce NaN bounds.
3406        let mut scope = scope(50.0);
3407        scope.draw_arc(
3408            Brush::solid(Color::RED),
3409            Point::new(25.0, 25.0),
3410            0.0,
3411            0.0,
3412            FRAC_PI_2,
3413            Stroke::new(6.0).with_cap(StrokeCap::Round),
3414        );
3415        let primitives = scope.into_primitives();
3416        assert_eq!(primitives.len(), 1);
3417        let DrawPrimitive::Arc { rect, .. } = &primitives[0] else {
3418            panic!("expected arc");
3419        };
3420        for value in [rect.x, rect.y, rect.width, rect.height] {
3421            assert!(value.is_finite(), "{rect:?}");
3422        }
3423        assert!(rect.width > 0.0 && rect.height > 0.0, "{rect:?}");
3424    }
3425
3426    // ── Text ────────────────────────────────────────────────────────────────
3427
3428    /// Measures every character as a fixed box, so a test can predict the block
3429    /// a draw is supposed to occupy without depending on a font.
3430    struct FixedAdvanceTextMeasurer {
3431        advance: f32,
3432        line_height: f32,
3433        calls: std::cell::Cell<usize>,
3434    }
3435
3436    impl FixedAdvanceTextMeasurer {
3437        fn shared(advance: f32, line_height: f32) -> Rc<Self> {
3438            Rc::new(Self {
3439                advance,
3440                line_height,
3441                calls: std::cell::Cell::new(0),
3442            })
3443        }
3444    }
3445
3446    impl DrawTextMeasurer for FixedAdvanceTextMeasurer {
3447        fn measure_text(&self, text: &str, _style: &TextStyle) -> TextMeasurement {
3448            self.calls.set(self.calls.get() + 1);
3449            let lines: Vec<&str> = text.split('\n').collect();
3450            let width = lines
3451                .iter()
3452                .map(|line| line.chars().count() as f32 * self.advance)
3453                .fold(0.0_f32, f32::max);
3454            TextMeasurement {
3455                size: Size::new(width, lines.len() as f32 * self.line_height),
3456                line_height: self.line_height,
3457                first_baseline: self.line_height * 0.75,
3458                line_count: lines.len(),
3459            }
3460        }
3461    }
3462
3463    fn text_scope(size: Size) -> (DrawScopeDefault, Rc<FixedAdvanceTextMeasurer>) {
3464        let measurer = FixedAdvanceTextMeasurer::shared(10.0, 20.0);
3465        (
3466            DrawScopeDefault::with_text_measurer(size, measurer.clone()),
3467            measurer,
3468        )
3469    }
3470
3471    fn unwrap_text(primitive: &DrawPrimitive) -> &TextPrimitive {
3472        match primitive {
3473            DrawPrimitive::Text(text) => text,
3474            other => panic!("expected text primitive, got {other:?}"),
3475        }
3476    }
3477
3478    #[test]
3479    fn drawn_text_occupies_exactly_the_box_measure_text_reported() {
3480        let (mut scope, _) = text_scope(Size::new(200.0, 100.0));
3481        let style = TextStyle::new(16.0);
3482        let measured = scope.measure_text("ABCD", &style);
3483
3484        scope.draw_text_from(
3485            Point::new(7.0, 11.0),
3486            Brush::solid(Color::WHITE),
3487            "ABCD",
3488            &style,
3489        );
3490
3491        let primitives = scope.into_primitives();
3492        assert_eq!(primitives.len(), 1);
3493        let text = unwrap_text(&primitives[0]);
3494        assert_eq!(
3495            text.rect,
3496            Rect {
3497                x: 7.0,
3498                y: 11.0,
3499                width: measured.size.width,
3500                height: measured.size.height,
3501            },
3502            "the drawn block must be the measured block, or callers cannot center text"
3503        );
3504        assert_eq!(&*text.text, "ABCD");
3505        assert_eq!(text.color, Color::WHITE);
3506    }
3507
3508    #[test]
3509    fn text_alignment_positions_the_measured_block_inside_the_box() {
3510        let box_rect = Rect {
3511            x: 100.0,
3512            y: 50.0,
3513            width: 200.0,
3514            height: 80.0,
3515        };
3516        // "AB" measures 20x20 with the fixed-advance measurer.
3517        let cases = [
3518            (TextAlign::Left, TextVerticalAlign::Top, 100.0, 50.0),
3519            (TextAlign::Center, TextVerticalAlign::Center, 190.0, 80.0),
3520            (TextAlign::Right, TextVerticalAlign::Bottom, 280.0, 110.0),
3521        ];
3522        for (align, vertical_align, expected_x, expected_y) in cases {
3523            let (mut scope, _) = text_scope(Size::new(400.0, 400.0));
3524            let style = TextStyle::new(16.0)
3525                .with_align(align)
3526                .with_vertical_align(vertical_align);
3527            scope.draw_text_at(box_rect, Brush::solid(Color::WHITE), "AB", &style);
3528            let primitives = scope.into_primitives();
3529            let text = unwrap_text(&primitives[0]);
3530            assert!(
3531                approx(text.rect.x, expected_x) && approx(text.rect.y, expected_y),
3532                "{align:?}/{vertical_align:?} placed the block at {:?}",
3533                text.rect
3534            );
3535            assert!(approx(text.rect.width, 20.0) && approx(text.rect.height, 20.0));
3536        }
3537    }
3538
3539    #[test]
3540    fn baseline_aligned_text_hangs_above_the_box_edge() {
3541        let (mut scope, _) = text_scope(Size::new(200.0, 200.0));
3542        let style = TextStyle::new(16.0).with_vertical_align(TextVerticalAlign::Baseline);
3543        let measured = scope.measure_text("Ag", &style);
3544        scope.draw_text_at(
3545            Rect {
3546                x: 0.0,
3547                y: 100.0,
3548                width: 200.0,
3549                height: 0.0,
3550            },
3551            Brush::solid(Color::WHITE),
3552            "Ag",
3553            &style,
3554        );
3555        let primitives = scope.into_primitives();
3556        let text = unwrap_text(&primitives[0]);
3557        // The box edge is the baseline, so the block starts one ascent above it.
3558        assert!(
3559            approx(text.rect.y, 100.0 - measured.first_baseline),
3560            "{:?}",
3561            text.rect
3562        );
3563    }
3564
3565    #[test]
3566    fn draw_text_fills_the_whole_scope_rect() {
3567        let (mut scope, _) = text_scope(Size::new(120.0, 60.0));
3568        let style = TextStyle::new(16.0)
3569            .with_align(TextAlign::Right)
3570            .with_vertical_align(TextVerticalAlign::Bottom);
3571        scope.draw_text(Brush::solid(Color::WHITE), "AB", &style);
3572        let primitives = scope.into_primitives();
3573        let text = unwrap_text(&primitives[0]);
3574        assert!(
3575            approx(text.rect.x, 100.0) && approx(text.rect.y, 40.0),
3576            "{:?}",
3577            text.rect
3578        );
3579    }
3580
3581    #[test]
3582    fn draw_text_from_ignores_alignment_and_anchors_the_top_left() {
3583        let (mut scope, _) = text_scope(Size::new(400.0, 400.0));
3584        // Alignment would move the block if the anchor form honored it.
3585        let style = TextStyle::new(16.0)
3586            .with_align(TextAlign::Center)
3587            .with_vertical_align(TextVerticalAlign::Bottom);
3588        scope.draw_text_from(
3589            Point::new(30.0, 40.0),
3590            Brush::solid(Color::WHITE),
3591            "AB",
3592            &style,
3593        );
3594        let primitives = scope.into_primitives();
3595        let text = unwrap_text(&primitives[0]);
3596        assert!(
3597            approx(text.rect.x, 30.0) && approx(text.rect.y, 40.0),
3598            "{:?}",
3599            text.rect
3600        );
3601    }
3602
3603    #[test]
3604    fn multiline_text_measures_the_widest_line_and_stacks_the_lines() {
3605        let (mut scope, _) = text_scope(Size::new(400.0, 400.0));
3606        let style = TextStyle::new(16.0);
3607        scope.draw_text_from(Point::ZERO, Brush::solid(Color::WHITE), "AB\nABCDE", &style);
3608        let primitives = scope.into_primitives();
3609        let text = unwrap_text(&primitives[0]);
3610        assert!(approx(text.rect.width, 50.0), "{:?}", text.rect);
3611        assert!(approx(text.rect.height, 40.0), "{:?}", text.rect);
3612    }
3613
3614    #[test]
3615    fn empty_text_draws_nothing_and_never_measures() {
3616        let (mut scope, measurer) = text_scope(Size::new(100.0, 100.0));
3617        scope.draw_text(Brush::solid(Color::WHITE), "", &TextStyle::new(16.0));
3618        scope.draw_text_at(
3619            Rect::from_size(Size::new(10.0, 10.0)),
3620            Brush::solid(Color::WHITE),
3621            "",
3622            &TextStyle::new(16.0),
3623        );
3624        scope.draw_text_from(
3625            Point::ZERO,
3626            Brush::solid(Color::WHITE),
3627            "",
3628            &TextStyle::new(16.0),
3629        );
3630        assert!(scope.into_primitives().is_empty());
3631        assert_eq!(
3632            measurer.calls.get(),
3633            0,
3634            "an empty string must not cost a measurement"
3635        );
3636    }
3637
3638    #[test]
3639    fn invisible_text_draws_nothing() {
3640        let (mut scope, _) = text_scope(Size::new(100.0, 100.0));
3641        let style = TextStyle::new(16.0);
3642        scope.draw_text(Brush::solid(Color(1.0, 1.0, 1.0, 0.0)), "AB", &style);
3643        scope.draw_text(
3644            Brush::LinearGradient {
3645                colors: Vec::new(),
3646                stops: None,
3647                start: Point::ZERO,
3648                end: Point::new(1.0, 1.0),
3649                tile_mode: crate::render_effect::TileMode::Clamp,
3650            },
3651            "AB",
3652            &style,
3653        );
3654        assert!(scope.into_primitives().is_empty());
3655    }
3656
3657    #[test]
3658    fn gradient_text_brushes_fall_back_to_their_first_stop() {
3659        let (mut scope, _) = text_scope(Size::new(100.0, 100.0));
3660        scope.draw_text(
3661            Brush::linear_gradient(vec![Color::RED, Color::BLUE]),
3662            "AB",
3663            &TextStyle::new(16.0),
3664        );
3665        let primitives = scope.into_primitives();
3666        assert_eq!(unwrap_text(&primitives[0]).color, Color::RED);
3667    }
3668
3669    #[test]
3670    fn a_scope_without_a_measurer_falls_back_to_the_font_free_estimate() {
3671        let mut scope = DrawScopeDefault::new(Size::new(100.0, 100.0));
3672        let style = TextStyle::new(16.0);
3673        assert_eq!(
3674            scope.measure_text("ABC", &style),
3675            crate::estimate_text_measurement("ABC", &style)
3676        );
3677        scope.draw_text_from(Point::ZERO, Brush::solid(Color::WHITE), "ABC", &style);
3678        let primitives = scope.into_primitives();
3679        let text = unwrap_text(&primitives[0]);
3680        assert!(text.rect.width > 0.0 && text.rect.height > 0.0);
3681    }
3682
3683    #[test]
3684    fn degenerate_text_geometry_emits_nothing_and_never_panics() {
3685        struct DegenerateTextMeasurer;
3686        impl DrawTextMeasurer for DegenerateTextMeasurer {
3687            fn measure_text(&self, _text: &str, _style: &TextStyle) -> TextMeasurement {
3688                TextMeasurement {
3689                    size: Size::new(f32::NAN, 0.0),
3690                    line_height: f32::NAN,
3691                    first_baseline: f32::NAN,
3692                    line_count: 1,
3693                }
3694            }
3695        }
3696
3697        let mut scope = DrawScopeDefault::with_text_measurer(
3698            Size::new(50.0, 50.0),
3699            Rc::new(DegenerateTextMeasurer),
3700        );
3701        scope.draw_text(Brush::solid(Color::WHITE), "AB", &TextStyle::new(16.0));
3702        scope.draw_text_at(
3703            Rect {
3704                x: f32::NAN,
3705                y: 0.0,
3706                width: 10.0,
3707                height: 10.0,
3708            },
3709            Brush::solid(Color::WHITE),
3710            "AB",
3711            &TextStyle::new(16.0),
3712        );
3713        assert!(
3714            scope.into_primitives().is_empty(),
3715            "unmeasurable text must not reach the renderer"
3716        );
3717    }
3718
3719    #[test]
3720    fn text_style_survives_lowering_into_the_primitive() {
3721        let (mut scope, _) = text_scope(Size::new(100.0, 100.0));
3722        let style = TextStyle::new(21.0)
3723            .with_font_family("Fira Sans")
3724            .with_weight(FontWeight::BOLD)
3725            .with_style(FontStyle::Italic)
3726            .with_letter_spacing(2.0)
3727            .with_line_height(26.0);
3728        scope.draw_text(Brush::solid(Color::WHITE), "AB", &style);
3729        let primitives = scope.into_primitives();
3730        assert_eq!(unwrap_text(&primitives[0]).style, style);
3731    }
3732
3733    #[test]
3734    fn a_layers_composite_alpha_is_a_truncated_byte() {
3735        for byte in 0..=255u32 {
3736            let exact = byte as f32 / 255.0;
3737            assert!(
3738                (GraphicsLayer::composite_alpha_8bit(exact) - exact).abs() < 1e-6,
3739                "byte {byte} moved"
3740            );
3741            if byte < 255 {
3742                // Anything above a byte and below the next composites at the
3743                // byte below it, however close to the next it sits. Rounding
3744                // would take the top of that range up, and HWUI's `(int)` does
3745                // not.
3746                let nearly_next = (byte as f32 + 0.999) / 255.0;
3747                assert!(
3748                    (GraphicsLayer::composite_alpha_8bit(nearly_next) - exact).abs() < 1e-6,
3749                    "byte {byte} + 0.999 did not truncate"
3750                );
3751            }
3752        }
3753        assert_eq!(GraphicsLayer::composite_alpha_8bit(1.0), 1.0);
3754        assert_eq!(GraphicsLayer::composite_alpha_8bit(0.0), 0.0);
3755        assert_eq!(GraphicsLayer::composite_alpha_8bit(-3.0), 0.0);
3756        assert_eq!(GraphicsLayer::composite_alpha_8bit(7.0), 1.0);
3757    }
3758}