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