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 pub brush: ResolvedBrush,
38 pub shape: Option<RoundedCornerShape>,
39 pub stroke: Option<Stroke>,
44 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
86pub struct TextDrawParams {
93 pub rect: Rect,
96 pub text: Rc<AnnotatedString>,
97 pub color: Color,
98 pub text_style: TextStyle,
99 pub font_size: f32,
100 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(
113 &mut self,
114 shadow_primitive: &ShadowPrimitive,
115 layer_bounds: Rect,
116 layer: &GraphicsLayer,
117 clip: Option<Rect>,
118 );
119
120 fn push_text(&mut self, params: TextDrawParams) {
124 let _ = params;
125 }
126}
127
128pub 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> {
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#[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#[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#[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}