Skip to main content

cranpose_render_common/
primitive_emit.rs

1use std::rc::Rc;
2
3use cranpose_ui::text::{
4    text_style_for_draw_style, AnnotatedString, TextLayoutOptions, TextOverflow, TextStyle,
5};
6use cranpose_ui_graphics::{
7    arc_band, inflate_rect, ArcGeometry, BlendMode, Brush, Color, ColorFilter, CornerRadii,
8    DrawPrimitive, GraphicsLayer, ImageBitmap, ImageSampling, Point, Rect, RoundedCornerShape,
9    ShadowPrimitive, Stroke, TextPrimitive,
10};
11
12use crate::graph::quad_bounds;
13use crate::layer_transform::{
14    apply_layer_affine_to_point, apply_layer_affine_to_rect, apply_layer_to_quad,
15    apply_layer_to_rect, layer_uniform_scale,
16};
17use crate::style_shared::{
18    apply_layer_to_brush, apply_layer_to_color, compose_color_filters, scale_corner_radii,
19};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum PrimitiveClipSpace {
23    Local,
24    LayerTransformed,
25}
26
27pub struct ShapeDrawParams {
28    pub rect: Rect,
29    pub local_rect: Rect,
30    pub quad: [[f32; 2]; 4],
31    pub brush: Brush,
32    pub shape: Option<RoundedCornerShape>,
33    /// `Some` means "stroke the outline of `local_rect`/`shape`" instead of
34    /// filling it. The width is already in `local_rect` units (the layer scale
35    /// has been applied), and `local_rect`/`quad` have already been inflated by
36    /// half that width so the outer half of the stroke has geometry to land on.
37    pub stroke: Option<Stroke>,
38    /// `Some` replaces the rect geometry entirely with a circular band. The
39    /// center and radii are in `local_rect` units.
40    pub arc: Option<ArcGeometry>,
41    pub clip: Option<Rect>,
42    pub blend_mode: BlendMode,
43    pub motion_context_animated: bool,
44}
45
46/// Resolves the rect a (possibly stroked) rect/round-rect primitive should
47/// actually cover, plus the layer-scaled stroke.
48///
49/// A centered stroke bleeds `width / 2` outside the geometry, so the quad has
50/// to grow by that much or the outer half of the outline would be clipped away
51/// by its own vertices. The shader shrinks the SDF box back by the same amount.
52///
53/// Returns `None` for a stroke that cannot draw anything.
54fn stroked_draw_rect(
55    local_rect: Rect,
56    stroke: Option<Stroke>,
57    layer_bounds: Rect,
58    layer: &GraphicsLayer,
59) -> Option<(Rect, Option<Stroke>)> {
60    let draw_rect = local_rect.translate(layer_bounds.x, layer_bounds.y);
61    let Some(stroke) = stroke else {
62        return Some((draw_rect, None));
63    };
64    if !stroke.is_visible() {
65        return None;
66    }
67    // Inflate in pre-scale local space; `apply_layer_affine_to_rect` then
68    // scales the padding by scale_x/scale_y while the stroke width itself uses
69    // the uniform (minimum) scale. The padding is therefore never smaller than
70    // the stroke needs.
71    let outset = stroke.half_width();
72    Some((
73        inflate_rect(draw_rect, outset),
74        Some(stroke.scaled(layer_uniform_scale(layer))),
75    ))
76}
77
78pub struct ImageDrawParams {
79    pub rect: Rect,
80    pub local_rect: Rect,
81    pub quad: [[f32; 2]; 4],
82    pub image: ImageBitmap,
83    pub alpha: f32,
84    pub color_filter: Option<ColorFilter>,
85    pub sampling: ImageSampling,
86    pub clip: Option<Rect>,
87    pub src_rect: Option<Rect>,
88    pub blend_mode: BlendMode,
89    pub motion_context_animated: bool,
90}
91
92/// A `DrawScope` text run, lowered into the vocabulary the text pipeline
93/// already speaks.
94///
95/// The fields line up one-for-one with `CompositorScene::push_text` in both
96/// backends, so a text primitive joins the same glyph atlas, run cache and
97/// shader the `Text` composable uses instead of getting a pipeline of its own.
98pub struct TextDrawParams {
99    /// Layer-transformed block box. Glyphs are laid out from its top-left; the
100    /// draw scope already resolved alignment into it.
101    pub rect: Rect,
102    pub text: Rc<AnnotatedString>,
103    pub color: Color,
104    pub text_style: TextStyle,
105    pub font_size: f32,
106    /// Uniform layer scale — the factor glyphs are rasterized at.
107    pub scale: f32,
108    pub layout_options: TextLayoutOptions,
109    pub clip: Option<Rect>,
110}
111
112pub trait DrawPrimitiveSink {
113    fn push_shape(&mut self, params: ShapeDrawParams);
114
115    fn push_image(&mut self, params: ImageDrawParams);
116
117    fn push_shadow(
118        &mut self,
119        shadow_primitive: ShadowPrimitive,
120        layer_bounds: Rect,
121        layer: &GraphicsLayer,
122        clip: Option<Rect>,
123    );
124
125    /// Draws a text run. The default drops it, for sinks that only collect
126    /// geometry (shadow casters, hit testing) and backends with no text
127    /// pipeline.
128    fn push_text(&mut self, params: TextDrawParams) {
129        let _ = params;
130    }
131}
132
133pub fn draw_shape_params_for_primitive(
134    primitive: DrawPrimitive,
135    layer_bounds: Rect,
136    layer: &GraphicsLayer,
137    clip: Option<Rect>,
138    blend_mode: BlendMode,
139) -> Option<ShapeDrawParams> {
140    struct SingleShapeSink {
141        shape: Option<ShapeDrawParams>,
142    }
143
144    impl DrawPrimitiveSink for SingleShapeSink {
145        fn push_shape(&mut self, params: ShapeDrawParams) {
146            if self.shape.is_none() {
147                self.shape = Some(params);
148            }
149        }
150
151        fn push_image(&mut self, _params: ImageDrawParams) {}
152
153        fn push_shadow(
154            &mut self,
155            _shadow_primitive: ShadowPrimitive,
156            _layer_bounds: Rect,
157            _layer: &GraphicsLayer,
158            _clip: Option<Rect>,
159        ) {
160        }
161    }
162
163    let mut sink = SingleShapeSink { shape: None };
164    emit_draw_primitive(
165        &primitive,
166        layer_bounds,
167        layer,
168        clip,
169        &mut sink,
170        Some(blend_mode),
171        false,
172    );
173    sink.shape
174}
175
176pub fn resolve_clip(parent_clip: Option<Rect>, requested_clip: Option<Rect>) -> Option<Rect> {
177    match (parent_clip, requested_clip) {
178        (Some(parent), Some(current)) => parent.intersect(current),
179        (Some(parent), None) => Some(parent),
180        (None, Some(current)) => Some(current),
181        (None, None) => None,
182    }
183}
184
185pub fn resolve_primitive_clip(
186    local_clip: Option<Rect>,
187    layer_bounds: Rect,
188    layer: &GraphicsLayer,
189    parent_clip: Option<Rect>,
190    clip_space: PrimitiveClipSpace,
191) -> Option<Rect> {
192    let Some(local_clip) = local_clip else {
193        return parent_clip;
194    };
195    let clip_rect = Rect {
196        x: layer_bounds.x + local_clip.x,
197        y: layer_bounds.y + local_clip.y,
198        width: local_clip.width,
199        height: local_clip.height,
200    };
201    let requested_clip = match clip_space {
202        PrimitiveClipSpace::Local => clip_rect,
203        PrimitiveClipSpace::LayerTransformed => apply_layer_to_rect(clip_rect, layer_bounds, layer),
204    };
205    resolve_clip(parent_clip, Some(requested_clip))
206}
207
208/// The [`DrawPrimitive::Rect`] arm of [`emit_draw_primitive`] as a pure
209/// builder over borrowed fields. The parallel shape-run collect calls these
210/// directly from worker threads — a `&DrawPrimitive` cannot cross (the text
211/// variant carries `Rc`), but the shape variants' fields can.
212#[allow(clippy::too_many_arguments)]
213pub fn rect_shape_params(
214    local_rect: Rect,
215    brush: &Brush,
216    stroke: Option<Stroke>,
217    layer_bounds: Rect,
218    layer: &GraphicsLayer,
219    clip: Option<Rect>,
220    blend_mode: BlendMode,
221    motion_context_animated: bool,
222) -> Option<ShapeDrawParams> {
223    let (draw_rect, stroke) = stroked_draw_rect(local_rect, stroke, layer_bounds, layer)?;
224    let local_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
225    let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
226    Some(ShapeDrawParams {
227        rect: quad_bounds(quad),
228        local_rect,
229        quad,
230        brush: apply_layer_to_brush(brush.clone(), layer),
231        shape: None,
232        stroke,
233        arc: None,
234        clip,
235        blend_mode,
236        motion_context_animated,
237    })
238}
239
240/// The [`DrawPrimitive::RoundRect`] arm of [`emit_draw_primitive`]; see
241/// [`rect_shape_params`].
242#[allow(clippy::too_many_arguments)]
243pub fn round_rect_shape_params(
244    local_rect: Rect,
245    brush: &Brush,
246    radii: CornerRadii,
247    stroke: Option<Stroke>,
248    layer_bounds: Rect,
249    layer: &GraphicsLayer,
250    clip: Option<Rect>,
251    blend_mode: BlendMode,
252    motion_context_animated: bool,
253) -> Option<ShapeDrawParams> {
254    let (draw_rect, stroke) = stroked_draw_rect(local_rect, stroke, layer_bounds, layer)?;
255    let local_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
256    let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
257    let shape =
258        RoundedCornerShape::with_radii(scale_corner_radii(radii, layer_uniform_scale(layer)));
259    Some(ShapeDrawParams {
260        rect: quad_bounds(quad),
261        local_rect,
262        quad,
263        brush: apply_layer_to_brush(brush.clone(), layer),
264        shape: Some(shape),
265        stroke,
266        arc: None,
267        clip,
268        blend_mode,
269        motion_context_animated,
270    })
271}
272
273/// The [`DrawPrimitive::Arc`] arm of [`emit_draw_primitive`]; see
274/// [`rect_shape_params`].
275#[allow(clippy::too_many_arguments)]
276pub fn arc_shape_params(
277    local_rect: Rect,
278    brush: &Brush,
279    center: Point,
280    radius: f32,
281    start_angle: f32,
282    sweep_angle: f32,
283    stroke: Option<Stroke>,
284    inner_radius: f32,
285    layer_bounds: Rect,
286    layer: &GraphicsLayer,
287    clip: Option<Rect>,
288    blend_mode: BlendMode,
289    motion_context_animated: bool,
290) -> Option<ShapeDrawParams> {
291    let (band_inner, band_outer, cap) = arc_band(radius, inner_radius, stroke);
292    let arc = ArcGeometry::new(
293        center,
294        band_inner,
295        band_outer,
296        start_angle,
297        sweep_angle,
298        cap,
299    );
300    if arc.is_degenerate() {
301        return None;
302    }
303    // `rect` already is the tight, cap-inclusive bounding box, so the
304    // quad needs no extra inflation here.
305    let draw_rect = local_rect.translate(layer_bounds.x, layer_bounds.y);
306    let out_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
307    let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
308    // Radii scale by the *uniform* (minimum) layer scale, matching how
309    // corner radii are handled. Under a non-uniform scale the true
310    // shape would be an ellipse; taking the minimum keeps the band
311    // strictly inside the (independently scaled) bounding box, so the
312    // quad never clips it.
313    let scale = layer_uniform_scale(layer);
314    let arc_center = apply_layer_affine_to_point(
315        Point::new(center.x + layer_bounds.x, center.y + layer_bounds.y),
316        layer_bounds,
317        layer,
318    );
319    Some(ShapeDrawParams {
320        rect: quad_bounds(quad),
321        local_rect: out_rect,
322        quad,
323        brush: apply_layer_to_brush(brush.clone(), layer),
324        shape: None,
325        stroke: None,
326        arc: Some(arc.scaled_about(arc_center, scale)),
327        clip,
328        blend_mode,
329        motion_context_animated,
330    })
331}
332
333pub fn emit_draw_primitive<S: DrawPrimitiveSink>(
334    primitive: &DrawPrimitive,
335    layer_bounds: Rect,
336    layer: &GraphicsLayer,
337    clip: Option<Rect>,
338    sink: &mut S,
339    blend_mode: Option<BlendMode>,
340    motion_context_animated: bool,
341) {
342    // Borrowing the primitive keeps the per-frame collect walk from cloning
343    // the whole enum for every draw; only the payloads a sink actually keeps
344    // (brush, image handle, text block, shadow) are cloned below.
345    match primitive {
346        DrawPrimitive::Content => {}
347        DrawPrimitive::Blend {
348            primitive,
349            blend_mode: nested,
350        } => emit_draw_primitive(
351            primitive,
352            layer_bounds,
353            layer,
354            clip,
355            sink,
356            blend_mode.or(Some(*nested)),
357            motion_context_animated,
358        ),
359        DrawPrimitive::Rect {
360            rect: local_rect,
361            brush,
362            stroke,
363        } => {
364            if let Some(params) = rect_shape_params(
365                *local_rect,
366                brush,
367                *stroke,
368                layer_bounds,
369                layer,
370                clip,
371                blend_mode.unwrap_or(BlendMode::SrcOver),
372                motion_context_animated,
373            ) {
374                sink.push_shape(params);
375            }
376        }
377        DrawPrimitive::RoundRect {
378            rect: local_rect,
379            brush,
380            radii,
381            stroke,
382        } => {
383            if let Some(params) = round_rect_shape_params(
384                *local_rect,
385                brush,
386                *radii,
387                *stroke,
388                layer_bounds,
389                layer,
390                clip,
391                blend_mode.unwrap_or(BlendMode::SrcOver),
392                motion_context_animated,
393            ) {
394                sink.push_shape(params);
395            }
396        }
397        DrawPrimitive::Arc {
398            rect: local_rect,
399            brush,
400            center,
401            radius,
402            start_angle,
403            sweep_angle,
404            stroke,
405            inner_radius,
406        } => {
407            if let Some(params) = arc_shape_params(
408                *local_rect,
409                brush,
410                *center,
411                *radius,
412                *start_angle,
413                *sweep_angle,
414                *stroke,
415                *inner_radius,
416                layer_bounds,
417                layer,
418                clip,
419                blend_mode.unwrap_or(BlendMode::SrcOver),
420                motion_context_animated,
421            ) {
422                sink.push_shape(params);
423            }
424        }
425        DrawPrimitive::Image {
426            rect: local_rect,
427            image,
428            alpha,
429            color_filter,
430            sampling,
431            src_rect,
432        } => {
433            let draw_rect = local_rect.translate(layer_bounds.x, layer_bounds.y);
434            let local_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
435            let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
436            sink.push_image(ImageDrawParams {
437                rect: quad_bounds(quad),
438                local_rect,
439                quad,
440                image: image.clone(),
441                alpha: (alpha * layer.alpha).clamp(0.0, 1.0),
442                color_filter: compose_color_filters(*color_filter, layer.color_filter),
443                sampling: *sampling,
444                clip,
445                src_rect: *src_rect,
446                blend_mode: blend_mode.unwrap_or(BlendMode::SrcOver),
447                motion_context_animated,
448            });
449        }
450        DrawPrimitive::Text(text) => {
451            if let Some(params) = text_draw_params((**text).clone(), layer_bounds, layer, clip) {
452                sink.push_text(params);
453            }
454        }
455        DrawPrimitive::Shadow(shadow_primitive) => {
456            sink.push_shadow(shadow_primitive.clone(), layer_bounds, layer, clip);
457        }
458    }
459}
460
461/// Lowers a text primitive into [`TextDrawParams`].
462///
463/// The block box is translated into layer space and transformed exactly like a
464/// `Text` node's rect, and the style is built by the *same*
465/// [`text_style_for_draw_style`] the draw scope measured through — so the
466/// glyphs the rasterizer lays out and the extent the caller was told about come
467/// from one description.
468///
469/// Returns `None` for geometry no rasterizer would draw, so degenerate text
470/// never reaches a scene.
471fn text_draw_params(
472    text: TextPrimitive,
473    layer_bounds: Rect,
474    layer: &GraphicsLayer,
475    clip: Option<Rect>,
476) -> Option<TextDrawParams> {
477    if text.text.is_empty() {
478        return None;
479    }
480    let draw_rect = text.rect.translate(layer_bounds.x, layer_bounds.y);
481    let rect = apply_layer_to_rect(draw_rect, layer_bounds, layer);
482    if !(rect.width > 0.0 && rect.height > 0.0) {
483        return None;
484    }
485    let scale = layer_uniform_scale(layer);
486    if !scale.is_finite() || scale <= 0.0 {
487        return None;
488    }
489    let color = apply_layer_to_color(text.color, layer);
490    if color.3 <= 0.0 {
491        return None;
492    }
493
494    Some(TextDrawParams {
495        rect,
496        text: cranpose_ui::text::shared_plain_annotated_string(text.text.as_ref()),
497        color,
498        text_style: text_style_for_draw_style(&text.style),
499        font_size: text.style.resolved_font_size(),
500        scale,
501        // A draw scope hands the renderer a box it measured itself: re-wrapping
502        // or ellipsizing it against that same box could only shorten text the
503        // caller already sized for.
504        layout_options: TextLayoutOptions {
505            soft_wrap: false,
506            overflow: TextOverflow::Visible,
507            ..TextLayoutOptions::default()
508        },
509        clip,
510    })
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use cranpose_ui_graphics::{Brush, Color, CornerRadii};
517
518    #[test]
519    fn draw_shape_params_for_primitive_returns_transformed_rect_shape() {
520        let shape = draw_shape_params_for_primitive(
521            DrawPrimitive::Rect {
522                rect: Rect {
523                    x: 2.0,
524                    y: 3.0,
525                    width: 8.0,
526                    height: 5.0,
527                },
528                brush: Brush::solid(Color::WHITE),
529                stroke: None,
530            },
531            Rect {
532                x: 10.0,
533                y: 20.0,
534                width: 40.0,
535                height: 30.0,
536            },
537            &GraphicsLayer::default(),
538            None,
539            BlendMode::SrcOver,
540        )
541        .expect("rect shape");
542
543        assert_eq!(
544            shape.rect,
545            Rect {
546                x: 12.0,
547                y: 23.0,
548                width: 8.0,
549                height: 5.0,
550            }
551        );
552        assert!(shape.shape.is_none());
553    }
554
555    #[test]
556    fn draw_shape_params_for_primitive_resolves_blended_round_rect() {
557        let shape = draw_shape_params_for_primitive(
558            DrawPrimitive::Blend {
559                primitive: Box::new(DrawPrimitive::RoundRect {
560                    rect: Rect {
561                        x: 1.0,
562                        y: 1.0,
563                        width: 10.0,
564                        height: 6.0,
565                    },
566                    brush: Brush::solid(Color::BLACK),
567                    radii: CornerRadii::uniform(4.0),
568                    stroke: None,
569                }),
570                blend_mode: BlendMode::DstOut,
571            },
572            Rect::from_size(cranpose_ui_graphics::Size {
573                width: 20.0,
574                height: 20.0,
575            }),
576            &GraphicsLayer::default(),
577            None,
578            BlendMode::SrcOver,
579        )
580        .expect("round rect shape");
581
582        assert_eq!(shape.blend_mode, BlendMode::SrcOver);
583        assert!(shape.shape.is_some());
584    }
585
586    #[test]
587    fn draw_shape_params_for_primitive_rejects_non_shape_primitives() {
588        assert!(draw_shape_params_for_primitive(
589            DrawPrimitive::Image {
590                rect: Rect::from_size(cranpose_ui_graphics::Size {
591                    width: 4.0,
592                    height: 4.0,
593                }),
594                image: cranpose_ui_graphics::ImageBitmap::from_rgba8(
595                    1,
596                    1,
597                    vec![255, 255, 255, 255],
598                )
599                .expect("image"),
600                alpha: 1.0,
601                color_filter: None,
602                sampling: ImageSampling::Nearest,
603                src_rect: None,
604            },
605            Rect::from_size(cranpose_ui_graphics::Size {
606                width: 10.0,
607                height: 10.0,
608            }),
609            &GraphicsLayer::default(),
610            None,
611            BlendMode::SrcOver,
612        )
613        .is_none());
614    }
615
616    // ── Stroke / arc lowering ───────────────────────────────────────────────
617
618    use cranpose_ui_graphics::{Stroke, StrokeCap, StrokeJoin};
619    use std::f32::consts::FRAC_PI_2;
620
621    fn approx(a: f32, b: f32) -> bool {
622        (a - b).abs() < 1e-3
623    }
624
625    fn layer_bounds() -> Rect {
626        Rect {
627            x: 10.0,
628            y: 20.0,
629            width: 100.0,
630            height: 100.0,
631        }
632    }
633
634    #[test]
635    fn stroked_rect_inflates_the_quad_by_half_the_width() {
636        // Without the inflation the outer half of the outline would be clipped
637        // away by the quad it is drawn on.
638        let params = draw_shape_params_for_primitive(
639            DrawPrimitive::Rect {
640                rect: Rect {
641                    x: 5.0,
642                    y: 5.0,
643                    width: 40.0,
644                    height: 30.0,
645                },
646                brush: Brush::solid(Color::WHITE),
647                stroke: Some(Stroke::new(6.0).with_join(StrokeJoin::Bevel)),
648            },
649            layer_bounds(),
650            &GraphicsLayer::default(),
651            None,
652            BlendMode::SrcOver,
653        )
654        .expect("stroked rect");
655
656        let stroke = params.stroke.expect("stroke must survive lowering");
657        assert_eq!(stroke.width, 6.0);
658        assert_eq!(stroke.join, StrokeJoin::Bevel);
659        // Geometry (15, 25) 40x30, grown by 3 on every side.
660        assert_eq!(
661            params.local_rect,
662            Rect {
663                x: 12.0,
664                y: 22.0,
665                width: 46.0,
666                height: 36.0,
667            }
668        );
669        assert_eq!(params.rect, params.local_rect);
670        assert!(params.arc.is_none());
671    }
672
673    #[test]
674    fn stroke_width_and_inflation_follow_the_layer_scale() {
675        let layer = GraphicsLayer {
676            scale: 2.0,
677            transform_origin: cranpose_ui_graphics::TransformOrigin::new(0.0, 0.0),
678            ..Default::default()
679        };
680        let params = draw_shape_params_for_primitive(
681            DrawPrimitive::Rect {
682                rect: Rect {
683                    x: 0.0,
684                    y: 0.0,
685                    width: 20.0,
686                    height: 20.0,
687                },
688                brush: Brush::solid(Color::WHITE),
689                stroke: Some(Stroke::new(4.0)),
690            },
691            Rect {
692                x: 0.0,
693                y: 0.0,
694                width: 20.0,
695                height: 20.0,
696            },
697            &layer,
698            None,
699            BlendMode::SrcOver,
700        )
701        .expect("stroked rect");
702
703        assert_eq!(params.stroke.expect("stroke").width, 8.0);
704        // Geometry (-2,-2)..(22,22) scaled 2x about the origin.
705        assert_eq!(
706            params.local_rect,
707            Rect {
708                x: -4.0,
709                y: -4.0,
710                width: 48.0,
711                height: 48.0,
712            }
713        );
714    }
715
716    #[test]
717    fn zero_width_stroke_emits_nothing() {
718        for width in [0.0, -2.0, f32::NAN] {
719            assert!(
720                draw_shape_params_for_primitive(
721                    DrawPrimitive::Rect {
722                        rect: Rect::from_size(cranpose_ui_graphics::Size {
723                            width: 10.0,
724                            height: 10.0,
725                        }),
726                        brush: Brush::solid(Color::WHITE),
727                        stroke: Some(Stroke::new(width)),
728                    },
729                    layer_bounds(),
730                    &GraphicsLayer::default(),
731                    None,
732                    BlendMode::SrcOver,
733                )
734                .is_none(),
735                "stroke width {width} must not reach the renderer"
736            );
737        }
738    }
739
740    #[test]
741    fn arc_lowers_to_a_band_translated_into_layer_space() {
742        let arc_rect = Rect {
743            x: 50.0,
744            y: 50.0,
745            width: 12.0,
746            height: 12.0,
747        };
748        let params = draw_shape_params_for_primitive(
749            DrawPrimitive::Arc {
750                rect: arc_rect,
751                brush: Brush::solid(Color::WHITE),
752                center: Point::new(50.0, 50.0),
753                radius: 12.0,
754                start_angle: 0.0,
755                sweep_angle: FRAC_PI_2,
756                stroke: None,
757                inner_radius: 6.0,
758            },
759            layer_bounds(),
760            &GraphicsLayer::default(),
761            None,
762            BlendMode::SrcOver,
763        )
764        .expect("arc");
765
766        let arc = params.arc.expect("arc geometry must survive lowering");
767        // Center translated by the layer origin, exactly like the bbox.
768        assert_eq!(arc.center, Point::new(60.0, 70.0));
769        assert_eq!(arc.inner_radius, 6.0);
770        assert_eq!(arc.outer_radius, 12.0);
771        assert_eq!(arc.cap, StrokeCap::Butt, "a filled sector has flat ends");
772        assert!(approx(arc.sweep_angle, FRAC_PI_2));
773        assert!(params.stroke.is_none());
774        assert!(params.shape.is_none());
775        assert_eq!(params.local_rect, arc_rect.translate(10.0, 20.0));
776    }
777
778    #[test]
779    fn stroked_arc_lowers_to_the_band_around_the_radius() {
780        let params = draw_shape_params_for_primitive(
781            DrawPrimitive::Arc {
782                rect: Rect {
783                    x: 0.0,
784                    y: 0.0,
785                    width: 60.0,
786                    height: 60.0,
787                },
788                brush: Brush::solid(Color::WHITE),
789                center: Point::new(30.0, 30.0),
790                radius: 20.0,
791                start_angle: 0.0,
792                sweep_angle: 1.0,
793                stroke: Some(Stroke::new(8.0).with_cap(StrokeCap::Round)),
794                inner_radius: 0.0,
795            },
796            Rect::from_size(cranpose_ui_graphics::Size {
797                width: 60.0,
798                height: 60.0,
799            }),
800            &GraphicsLayer::default(),
801            None,
802            BlendMode::SrcOver,
803        )
804        .expect("stroked arc");
805
806        let arc = params.arc.expect("arc geometry");
807        assert_eq!(arc.inner_radius, 16.0);
808        assert_eq!(arc.outer_radius, 24.0);
809        assert_eq!(arc.cap, StrokeCap::Round);
810        assert!(
811            params.stroke.is_none(),
812            "an arc carries its width in the band radii, not in `stroke`"
813        );
814    }
815
816    #[test]
817    fn arc_radii_and_center_follow_the_layer_transform() {
818        let layer = GraphicsLayer {
819            scale: 3.0,
820            transform_origin: cranpose_ui_graphics::TransformOrigin::new(0.0, 0.0),
821            ..Default::default()
822        };
823        let params = draw_shape_params_for_primitive(
824            DrawPrimitive::Arc {
825                rect: Rect {
826                    x: 0.0,
827                    y: 0.0,
828                    width: 20.0,
829                    height: 20.0,
830                },
831                brush: Brush::solid(Color::WHITE),
832                center: Point::new(10.0, 10.0),
833                radius: 10.0,
834                start_angle: 0.0,
835                sweep_angle: 1.0,
836                stroke: None,
837                inner_radius: 4.0,
838            },
839            Rect {
840                x: 0.0,
841                y: 0.0,
842                width: 20.0,
843                height: 20.0,
844            },
845            &layer,
846            None,
847            BlendMode::SrcOver,
848        )
849        .expect("arc");
850
851        let arc = params.arc.expect("arc geometry");
852        assert_eq!(arc.center, Point::new(30.0, 30.0));
853        assert_eq!(arc.inner_radius, 12.0);
854        assert_eq!(arc.outer_radius, 30.0);
855        // The band must stay inside the quad the renderer emits.
856        assert_eq!(
857            params.local_rect,
858            Rect {
859                x: 0.0,
860                y: 0.0,
861                width: 60.0,
862                height: 60.0,
863            }
864        );
865    }
866
867    #[test]
868    fn degenerate_arcs_emit_nothing() {
869        let base_rect = Rect::from_size(cranpose_ui_graphics::Size {
870            width: 20.0,
871            height: 20.0,
872        });
873        let cases: [(f32, f32, f32, Option<Stroke>); 4] = [
874            // inner >= outer
875            (10.0, 10.0, 1.0, None),
876            // zero sweep
877            (10.0, 0.0, 0.0, None),
878            // zero radius
879            (0.0, 0.0, 1.0, None),
880            // zero-width stroke
881            (10.0, 0.0, 1.0, Some(Stroke::new(0.0))),
882        ];
883        for (radius, inner_radius, sweep_angle, stroke) in cases {
884            assert!(
885                draw_shape_params_for_primitive(
886                    DrawPrimitive::Arc {
887                        rect: base_rect,
888                        brush: Brush::solid(Color::WHITE),
889                        center: Point::new(10.0, 10.0),
890                        radius,
891                        start_angle: 0.0,
892                        sweep_angle,
893                        stroke,
894                        inner_radius,
895                    },
896                    layer_bounds(),
897                    &GraphicsLayer::default(),
898                    None,
899                    BlendMode::SrcOver,
900                )
901                .is_none(),
902                "degenerate arc (r={radius}, inner={inner_radius}, sweep={sweep_angle}) \
903                 must not reach the renderer"
904            );
905        }
906    }
907
908    #[test]
909    fn fills_still_lower_without_stroke_or_arc() {
910        let params = draw_shape_params_for_primitive(
911            DrawPrimitive::RoundRect {
912                rect: Rect::from_size(cranpose_ui_graphics::Size {
913                    width: 10.0,
914                    height: 10.0,
915                }),
916                brush: Brush::solid(Color::WHITE),
917                radii: CornerRadii::uniform(2.0),
918                stroke: None,
919            },
920            layer_bounds(),
921            &GraphicsLayer::default(),
922            None,
923            BlendMode::SrcOver,
924        )
925        .expect("round rect");
926        assert!(params.stroke.is_none());
927        assert!(params.arc.is_none());
928        assert!(params.shape.is_some());
929    }
930
931    // ── Text lowering ───────────────────────────────────────────────────────
932
933    use cranpose_ui_graphics::{
934        FontWeight as DrawFontWeight, TextPrimitive, TextStyle as DrawTextStyle,
935    };
936    use std::rc::Rc as StdRc;
937
938    #[derive(Default)]
939    struct CollectingTextSink {
940        texts: Vec<TextDrawParams>,
941    }
942
943    impl DrawPrimitiveSink for CollectingTextSink {
944        fn push_shape(&mut self, _params: ShapeDrawParams) {}
945        fn push_image(&mut self, _params: ImageDrawParams) {}
946        fn push_shadow(
947            &mut self,
948            _shadow_primitive: ShadowPrimitive,
949            _layer_bounds: Rect,
950            _layer: &GraphicsLayer,
951            _clip: Option<Rect>,
952        ) {
953        }
954        fn push_text(&mut self, params: TextDrawParams) {
955            self.texts.push(params);
956        }
957    }
958
959    fn text_primitive(rect: Rect, text: &str, style: DrawTextStyle) -> DrawPrimitive {
960        DrawPrimitive::Text(Box::new(TextPrimitive {
961            rect,
962            text: StdRc::from(text),
963            style,
964            color: Color::WHITE,
965        }))
966    }
967
968    fn lower_text(primitive: DrawPrimitive, layer: &GraphicsLayer) -> Vec<TextDrawParams> {
969        let mut sink = CollectingTextSink::default();
970        emit_draw_primitive(
971            &primitive,
972            layer_bounds(),
973            layer,
974            None,
975            &mut sink,
976            None,
977            false,
978        );
979        sink.texts
980    }
981
982    #[test]
983    fn text_lowers_into_the_layer_translated_block_the_scope_measured() {
984        let params = lower_text(
985            text_primitive(
986                Rect {
987                    x: 5.0,
988                    y: 6.0,
989                    width: 40.0,
990                    height: 18.0,
991                },
992                "SCORE",
993                DrawTextStyle::new(12.0),
994            ),
995            &GraphicsLayer::default(),
996        );
997        assert_eq!(params.len(), 1);
998        // Layer bounds are (10, 20); an untransformed layer only translates.
999        assert_eq!(
1000            params[0].rect,
1001            Rect {
1002                x: 15.0,
1003                y: 26.0,
1004                width: 40.0,
1005                height: 18.0,
1006            }
1007        );
1008        assert_eq!(params[0].text.text, "SCORE");
1009        assert_eq!(params[0].font_size, 12.0);
1010        assert_eq!(params[0].scale, 1.0);
1011        assert_eq!(params[0].color, Color::WHITE);
1012    }
1013
1014    #[test]
1015    fn text_lowering_carries_the_uniform_layer_scale_for_rasterization() {
1016        let layer = GraphicsLayer {
1017            scale: 2.0,
1018            transform_origin: cranpose_ui_graphics::TransformOrigin::new(0.0, 0.0),
1019            ..Default::default()
1020        };
1021        let params = lower_text(
1022            text_primitive(
1023                Rect {
1024                    x: 0.0,
1025                    y: 0.0,
1026                    width: 30.0,
1027                    height: 14.0,
1028                },
1029                "AB",
1030                DrawTextStyle::new(10.0),
1031            ),
1032            &layer,
1033        );
1034        assert_eq!(params[0].scale, 2.0, "glyphs rasterize at the layer scale");
1035        assert!(approx(params[0].rect.width, 60.0), "{:?}", params[0].rect);
1036    }
1037
1038    #[test]
1039    fn text_lowering_folds_the_layer_alpha_into_the_glyph_color() {
1040        let layer = GraphicsLayer {
1041            alpha: 0.5,
1042            ..Default::default()
1043        };
1044        let params = lower_text(
1045            text_primitive(
1046                Rect {
1047                    x: 0.0,
1048                    y: 0.0,
1049                    width: 30.0,
1050                    height: 14.0,
1051                },
1052                "AB",
1053                DrawTextStyle::new(10.0),
1054            ),
1055            &layer,
1056        );
1057        assert!(approx(params[0].color.3, 0.5));
1058    }
1059
1060    #[test]
1061    fn text_lowering_uses_the_same_style_translation_the_draw_scope_measured_with() {
1062        let style = DrawTextStyle::new(18.0)
1063            .with_font_family("Fira Sans")
1064            .with_weight(DrawFontWeight::BOLD);
1065        let params = lower_text(
1066            text_primitive(
1067                Rect {
1068                    x: 0.0,
1069                    y: 0.0,
1070                    width: 30.0,
1071                    height: 20.0,
1072                },
1073                "AB",
1074                style.clone(),
1075            ),
1076            &GraphicsLayer::default(),
1077        );
1078        assert_eq!(params[0].text_style, text_style_for_draw_style(&style));
1079    }
1080
1081    #[test]
1082    fn lowered_text_is_never_re_wrapped_against_the_box_it_was_measured_into() {
1083        // The draw scope already sized the box from the string; wrapping or
1084        // ellipsizing here could only truncate text the caller asked for.
1085        let params = lower_text(
1086            text_primitive(
1087                Rect {
1088                    x: 0.0,
1089                    y: 0.0,
1090                    width: 4.0,
1091                    height: 14.0,
1092                },
1093                "a very long line",
1094                DrawTextStyle::new(10.0),
1095            ),
1096            &GraphicsLayer::default(),
1097        );
1098        assert!(!params[0].layout_options.soft_wrap);
1099        assert_eq!(params[0].layout_options.overflow, TextOverflow::Visible);
1100    }
1101
1102    #[test]
1103    fn degenerate_text_never_reaches_a_sink() {
1104        let invisible_layer = GraphicsLayer {
1105            alpha: 0.0,
1106            ..Default::default()
1107        };
1108        let cases: [(DrawPrimitive, GraphicsLayer); 3] = [
1109            (
1110                text_primitive(
1111                    Rect {
1112                        x: 0.0,
1113                        y: 0.0,
1114                        width: 20.0,
1115                        height: 10.0,
1116                    },
1117                    "",
1118                    DrawTextStyle::new(10.0),
1119                ),
1120                GraphicsLayer::default(),
1121            ),
1122            (
1123                text_primitive(
1124                    Rect {
1125                        x: 0.0,
1126                        y: 0.0,
1127                        width: 0.0,
1128                        height: 10.0,
1129                    },
1130                    "AB",
1131                    DrawTextStyle::new(10.0),
1132                ),
1133                GraphicsLayer::default(),
1134            ),
1135            (
1136                text_primitive(
1137                    Rect {
1138                        x: 0.0,
1139                        y: 0.0,
1140                        width: 20.0,
1141                        height: 10.0,
1142                    },
1143                    "AB",
1144                    DrawTextStyle::new(10.0),
1145                ),
1146                invisible_layer,
1147            ),
1148        ];
1149        for (primitive, layer) in cases {
1150            assert!(
1151                lower_text(primitive, &layer).is_empty(),
1152                "degenerate text must not reach the renderer"
1153            );
1154        }
1155    }
1156
1157    #[test]
1158    fn blended_text_still_lowers_because_glyphs_composite_src_over() {
1159        // `DrawScope` offers no blended text, but a nested `Blend` wrapper must
1160        // not swallow the run if one is built by hand.
1161        let params = lower_text(
1162            DrawPrimitive::Blend {
1163                primitive: Box::new(text_primitive(
1164                    Rect {
1165                        x: 0.0,
1166                        y: 0.0,
1167                        width: 20.0,
1168                        height: 10.0,
1169                    },
1170                    "AB",
1171                    DrawTextStyle::new(10.0),
1172                )),
1173                blend_mode: BlendMode::DstOut,
1174            },
1175            &GraphicsLayer::default(),
1176        );
1177        assert_eq!(params.len(), 1);
1178    }
1179}