Skip to main content

cranpose_ui_graphics/
geometry.rs

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