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