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