Skip to main content

cranpose_render_common/
primitive_emit.rs

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