1use std::rc::Rc;
2
3use cranpose_ui::text::{
4 text_style_for_draw_style, AnnotatedString, TextLayoutOptions, TextOverflow, TextStyle,
5};
6use cranpose_ui_graphics::{
7 arc_band, inflate_rect, ArcGeometry, BlendMode, Brush, Color, ColorFilter, CornerRadii,
8 DrawPrimitive, GraphicsLayer, ImageBitmap, ImageSampling, Point, Rect, RoundedCornerShape,
9 ShadowPrimitive, Stroke, TextPrimitive,
10};
11
12use crate::graph::quad_bounds;
13use crate::layer_transform::{
14 apply_layer_affine_to_point, apply_layer_affine_to_rect, apply_layer_to_quad,
15 apply_layer_to_rect, layer_uniform_scale,
16};
17use crate::style_shared::{
18 apply_layer_to_brush, apply_layer_to_color, compose_color_filters, scale_corner_radii,
19};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum PrimitiveClipSpace {
23 Local,
24 LayerTransformed,
25}
26
27pub struct ShapeDrawParams {
28 pub rect: Rect,
29 pub local_rect: Rect,
30 pub quad: [[f32; 2]; 4],
31 pub brush: Brush,
32 pub shape: Option<RoundedCornerShape>,
33 pub stroke: Option<Stroke>,
38 pub arc: Option<ArcGeometry>,
41 pub clip: Option<Rect>,
42 pub blend_mode: BlendMode,
43 pub motion_context_animated: bool,
44}
45
46fn stroked_draw_rect(
55 local_rect: Rect,
56 stroke: Option<Stroke>,
57 layer_bounds: Rect,
58 layer: &GraphicsLayer,
59) -> Option<(Rect, Option<Stroke>)> {
60 let draw_rect = local_rect.translate(layer_bounds.x, layer_bounds.y);
61 let Some(stroke) = stroke else {
62 return Some((draw_rect, None));
63 };
64 if !stroke.is_visible() {
65 return None;
66 }
67 let outset = stroke.half_width();
72 Some((
73 inflate_rect(draw_rect, outset),
74 Some(stroke.scaled(layer_uniform_scale(layer))),
75 ))
76}
77
78pub struct ImageDrawParams {
79 pub rect: Rect,
80 pub local_rect: Rect,
81 pub quad: [[f32; 2]; 4],
82 pub image: ImageBitmap,
83 pub alpha: f32,
84 pub color_filter: Option<ColorFilter>,
85 pub sampling: ImageSampling,
86 pub clip: Option<Rect>,
87 pub src_rect: Option<Rect>,
88 pub blend_mode: BlendMode,
89 pub motion_context_animated: bool,
90}
91
92pub struct TextDrawParams {
99 pub rect: Rect,
102 pub text: Rc<AnnotatedString>,
103 pub color: Color,
104 pub text_style: TextStyle,
105 pub font_size: f32,
106 pub scale: f32,
108 pub layout_options: TextLayoutOptions,
109 pub clip: Option<Rect>,
110}
111
112pub trait DrawPrimitiveSink {
113 fn push_shape(&mut self, params: ShapeDrawParams);
114
115 fn push_image(&mut self, params: ImageDrawParams);
116
117 fn push_shadow(
118 &mut self,
119 shadow_primitive: ShadowPrimitive,
120 layer_bounds: Rect,
121 layer: &GraphicsLayer,
122 clip: Option<Rect>,
123 );
124
125 fn push_text(&mut self, params: TextDrawParams) {
129 let _ = params;
130 }
131}
132
133pub fn draw_shape_params_for_primitive(
134 primitive: DrawPrimitive,
135 layer_bounds: Rect,
136 layer: &GraphicsLayer,
137 clip: Option<Rect>,
138 blend_mode: BlendMode,
139) -> Option<ShapeDrawParams> {
140 struct SingleShapeSink {
141 shape: Option<ShapeDrawParams>,
142 }
143
144 impl DrawPrimitiveSink for SingleShapeSink {
145 fn push_shape(&mut self, params: ShapeDrawParams) {
146 if self.shape.is_none() {
147 self.shape = Some(params);
148 }
149 }
150
151 fn push_image(&mut self, _params: ImageDrawParams) {}
152
153 fn push_shadow(
154 &mut self,
155 _shadow_primitive: ShadowPrimitive,
156 _layer_bounds: Rect,
157 _layer: &GraphicsLayer,
158 _clip: Option<Rect>,
159 ) {
160 }
161 }
162
163 let mut sink = SingleShapeSink { shape: None };
164 emit_draw_primitive(
165 &primitive,
166 layer_bounds,
167 layer,
168 clip,
169 &mut sink,
170 Some(blend_mode),
171 false,
172 );
173 sink.shape
174}
175
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)) => parent.intersect(current),
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: apply_layer_to_brush(brush.clone(), 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: apply_layer_to_brush(brush.clone(), 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);
306 let out_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
307 let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
308 let scale = layer_uniform_scale(layer);
314 let arc_center = apply_layer_affine_to_point(
315 Point::new(center.x + layer_bounds.x, center.y + layer_bounds.y),
316 layer_bounds,
317 layer,
318 );
319 Some(ShapeDrawParams {
320 rect: quad_bounds(quad),
321 local_rect: out_rect,
322 quad,
323 brush: apply_layer_to_brush(brush.clone(), layer),
324 shape: None,
325 stroke: None,
326 arc: Some(arc.scaled_about(arc_center, scale)),
327 clip,
328 blend_mode,
329 motion_context_animated,
330 })
331}
332
333pub fn emit_draw_primitive<S: DrawPrimitiveSink>(
334 primitive: &DrawPrimitive,
335 layer_bounds: Rect,
336 layer: &GraphicsLayer,
337 clip: Option<Rect>,
338 sink: &mut S,
339 blend_mode: Option<BlendMode>,
340 motion_context_animated: bool,
341) {
342 match primitive {
346 DrawPrimitive::Content => {}
347 DrawPrimitive::Blend {
348 primitive,
349 blend_mode: nested,
350 } => emit_draw_primitive(
351 primitive,
352 layer_bounds,
353 layer,
354 clip,
355 sink,
356 blend_mode.or(Some(*nested)),
357 motion_context_animated,
358 ),
359 DrawPrimitive::Rect {
360 rect: local_rect,
361 brush,
362 stroke,
363 } => {
364 if let Some(params) = rect_shape_params(
365 *local_rect,
366 brush,
367 *stroke,
368 layer_bounds,
369 layer,
370 clip,
371 blend_mode.unwrap_or(BlendMode::SrcOver),
372 motion_context_animated,
373 ) {
374 sink.push_shape(params);
375 }
376 }
377 DrawPrimitive::RoundRect {
378 rect: local_rect,
379 brush,
380 radii,
381 stroke,
382 } => {
383 if let Some(params) = round_rect_shape_params(
384 *local_rect,
385 brush,
386 *radii,
387 *stroke,
388 layer_bounds,
389 layer,
390 clip,
391 blend_mode.unwrap_or(BlendMode::SrcOver),
392 motion_context_animated,
393 ) {
394 sink.push_shape(params);
395 }
396 }
397 DrawPrimitive::Arc {
398 rect: local_rect,
399 brush,
400 center,
401 radius,
402 start_angle,
403 sweep_angle,
404 stroke,
405 inner_radius,
406 } => {
407 if let Some(params) = arc_shape_params(
408 *local_rect,
409 brush,
410 *center,
411 *radius,
412 *start_angle,
413 *sweep_angle,
414 *stroke,
415 *inner_radius,
416 layer_bounds,
417 layer,
418 clip,
419 blend_mode.unwrap_or(BlendMode::SrcOver),
420 motion_context_animated,
421 ) {
422 sink.push_shape(params);
423 }
424 }
425 DrawPrimitive::Image {
426 rect: local_rect,
427 image,
428 alpha,
429 color_filter,
430 sampling,
431 src_rect,
432 } => {
433 let draw_rect = local_rect.translate(layer_bounds.x, layer_bounds.y);
434 let local_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
435 let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
436 sink.push_image(ImageDrawParams {
437 rect: quad_bounds(quad),
438 local_rect,
439 quad,
440 image: image.clone(),
441 alpha: (alpha * layer.alpha).clamp(0.0, 1.0),
442 color_filter: compose_color_filters(*color_filter, layer.color_filter),
443 sampling: *sampling,
444 clip,
445 src_rect: *src_rect,
446 blend_mode: blend_mode.unwrap_or(BlendMode::SrcOver),
447 motion_context_animated,
448 });
449 }
450 DrawPrimitive::Text(text) => {
451 if let Some(params) = text_draw_params((**text).clone(), layer_bounds, layer, clip) {
452 sink.push_text(params);
453 }
454 }
455 DrawPrimitive::Shadow(shadow_primitive) => {
456 sink.push_shadow(shadow_primitive.clone(), layer_bounds, layer, clip);
457 }
458 }
459}
460
461fn text_draw_params(
472 text: TextPrimitive,
473 layer_bounds: Rect,
474 layer: &GraphicsLayer,
475 clip: Option<Rect>,
476) -> Option<TextDrawParams> {
477 if text.text.is_empty() {
478 return None;
479 }
480 let draw_rect = text.rect.translate(layer_bounds.x, layer_bounds.y);
481 let rect = apply_layer_to_rect(draw_rect, layer_bounds, layer);
482 if !(rect.width > 0.0 && rect.height > 0.0) {
483 return None;
484 }
485 let scale = layer_uniform_scale(layer);
486 if !scale.is_finite() || scale <= 0.0 {
487 return None;
488 }
489 let color = apply_layer_to_color(text.color, layer);
490 if color.3 <= 0.0 {
491 return None;
492 }
493
494 Some(TextDrawParams {
495 rect,
496 text: cranpose_ui::text::shared_plain_annotated_string(text.text.as_ref()),
497 color,
498 text_style: text_style_for_draw_style(&text.style),
499 font_size: text.style.resolved_font_size(),
500 scale,
501 layout_options: TextLayoutOptions {
505 soft_wrap: false,
506 overflow: TextOverflow::Visible,
507 ..TextLayoutOptions::default()
508 },
509 clip,
510 })
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use cranpose_ui_graphics::{Brush, Color, CornerRadii};
517
518 #[test]
519 fn draw_shape_params_for_primitive_returns_transformed_rect_shape() {
520 let shape = draw_shape_params_for_primitive(
521 DrawPrimitive::Rect {
522 rect: Rect {
523 x: 2.0,
524 y: 3.0,
525 width: 8.0,
526 height: 5.0,
527 },
528 brush: Brush::solid(Color::WHITE),
529 stroke: None,
530 },
531 Rect {
532 x: 10.0,
533 y: 20.0,
534 width: 40.0,
535 height: 30.0,
536 },
537 &GraphicsLayer::default(),
538 None,
539 BlendMode::SrcOver,
540 )
541 .expect("rect shape");
542
543 assert_eq!(
544 shape.rect,
545 Rect {
546 x: 12.0,
547 y: 23.0,
548 width: 8.0,
549 height: 5.0,
550 }
551 );
552 assert!(shape.shape.is_none());
553 }
554
555 #[test]
556 fn draw_shape_params_for_primitive_resolves_blended_round_rect() {
557 let shape = draw_shape_params_for_primitive(
558 DrawPrimitive::Blend {
559 primitive: Box::new(DrawPrimitive::RoundRect {
560 rect: Rect {
561 x: 1.0,
562 y: 1.0,
563 width: 10.0,
564 height: 6.0,
565 },
566 brush: Brush::solid(Color::BLACK),
567 radii: CornerRadii::uniform(4.0),
568 stroke: None,
569 }),
570 blend_mode: BlendMode::DstOut,
571 },
572 Rect::from_size(cranpose_ui_graphics::Size {
573 width: 20.0,
574 height: 20.0,
575 }),
576 &GraphicsLayer::default(),
577 None,
578 BlendMode::SrcOver,
579 )
580 .expect("round rect shape");
581
582 assert_eq!(shape.blend_mode, BlendMode::SrcOver);
583 assert!(shape.shape.is_some());
584 }
585
586 #[test]
587 fn draw_shape_params_for_primitive_rejects_non_shape_primitives() {
588 assert!(draw_shape_params_for_primitive(
589 DrawPrimitive::Image {
590 rect: Rect::from_size(cranpose_ui_graphics::Size {
591 width: 4.0,
592 height: 4.0,
593 }),
594 image: cranpose_ui_graphics::ImageBitmap::from_rgba8(
595 1,
596 1,
597 vec![255, 255, 255, 255],
598 )
599 .expect("image"),
600 alpha: 1.0,
601 color_filter: None,
602 sampling: ImageSampling::Nearest,
603 src_rect: None,
604 },
605 Rect::from_size(cranpose_ui_graphics::Size {
606 width: 10.0,
607 height: 10.0,
608 }),
609 &GraphicsLayer::default(),
610 None,
611 BlendMode::SrcOver,
612 )
613 .is_none());
614 }
615
616 use cranpose_ui_graphics::{Stroke, StrokeCap, StrokeJoin};
619 use std::f32::consts::FRAC_PI_2;
620
621 fn approx(a: f32, b: f32) -> bool {
622 (a - b).abs() < 1e-3
623 }
624
625 fn layer_bounds() -> Rect {
626 Rect {
627 x: 10.0,
628 y: 20.0,
629 width: 100.0,
630 height: 100.0,
631 }
632 }
633
634 #[test]
635 fn stroked_rect_inflates_the_quad_by_half_the_width() {
636 let params = draw_shape_params_for_primitive(
639 DrawPrimitive::Rect {
640 rect: Rect {
641 x: 5.0,
642 y: 5.0,
643 width: 40.0,
644 height: 30.0,
645 },
646 brush: Brush::solid(Color::WHITE),
647 stroke: Some(Stroke::new(6.0).with_join(StrokeJoin::Bevel)),
648 },
649 layer_bounds(),
650 &GraphicsLayer::default(),
651 None,
652 BlendMode::SrcOver,
653 )
654 .expect("stroked rect");
655
656 let stroke = params.stroke.expect("stroke must survive lowering");
657 assert_eq!(stroke.width, 6.0);
658 assert_eq!(stroke.join, StrokeJoin::Bevel);
659 assert_eq!(
661 params.local_rect,
662 Rect {
663 x: 12.0,
664 y: 22.0,
665 width: 46.0,
666 height: 36.0,
667 }
668 );
669 assert_eq!(params.rect, params.local_rect);
670 assert!(params.arc.is_none());
671 }
672
673 #[test]
674 fn stroke_width_and_inflation_follow_the_layer_scale() {
675 let layer = GraphicsLayer {
676 scale: 2.0,
677 transform_origin: cranpose_ui_graphics::TransformOrigin::new(0.0, 0.0),
678 ..Default::default()
679 };
680 let params = draw_shape_params_for_primitive(
681 DrawPrimitive::Rect {
682 rect: Rect {
683 x: 0.0,
684 y: 0.0,
685 width: 20.0,
686 height: 20.0,
687 },
688 brush: Brush::solid(Color::WHITE),
689 stroke: Some(Stroke::new(4.0)),
690 },
691 Rect {
692 x: 0.0,
693 y: 0.0,
694 width: 20.0,
695 height: 20.0,
696 },
697 &layer,
698 None,
699 BlendMode::SrcOver,
700 )
701 .expect("stroked rect");
702
703 assert_eq!(params.stroke.expect("stroke").width, 8.0);
704 assert_eq!(
706 params.local_rect,
707 Rect {
708 x: -4.0,
709 y: -4.0,
710 width: 48.0,
711 height: 48.0,
712 }
713 );
714 }
715
716 #[test]
717 fn zero_width_stroke_emits_nothing() {
718 for width in [0.0, -2.0, f32::NAN] {
719 assert!(
720 draw_shape_params_for_primitive(
721 DrawPrimitive::Rect {
722 rect: Rect::from_size(cranpose_ui_graphics::Size {
723 width: 10.0,
724 height: 10.0,
725 }),
726 brush: Brush::solid(Color::WHITE),
727 stroke: Some(Stroke::new(width)),
728 },
729 layer_bounds(),
730 &GraphicsLayer::default(),
731 None,
732 BlendMode::SrcOver,
733 )
734 .is_none(),
735 "stroke width {width} must not reach the renderer"
736 );
737 }
738 }
739
740 #[test]
741 fn arc_lowers_to_a_band_translated_into_layer_space() {
742 let arc_rect = Rect {
743 x: 50.0,
744 y: 50.0,
745 width: 12.0,
746 height: 12.0,
747 };
748 let params = draw_shape_params_for_primitive(
749 DrawPrimitive::Arc {
750 rect: arc_rect,
751 brush: Brush::solid(Color::WHITE),
752 center: Point::new(50.0, 50.0),
753 radius: 12.0,
754 start_angle: 0.0,
755 sweep_angle: FRAC_PI_2,
756 stroke: None,
757 inner_radius: 6.0,
758 },
759 layer_bounds(),
760 &GraphicsLayer::default(),
761 None,
762 BlendMode::SrcOver,
763 )
764 .expect("arc");
765
766 let arc = params.arc.expect("arc geometry must survive lowering");
767 assert_eq!(arc.center, Point::new(60.0, 70.0));
769 assert_eq!(arc.inner_radius, 6.0);
770 assert_eq!(arc.outer_radius, 12.0);
771 assert_eq!(arc.cap, StrokeCap::Butt, "a filled sector has flat ends");
772 assert!(approx(arc.sweep_angle, FRAC_PI_2));
773 assert!(params.stroke.is_none());
774 assert!(params.shape.is_none());
775 assert_eq!(params.local_rect, arc_rect.translate(10.0, 20.0));
776 }
777
778 #[test]
779 fn stroked_arc_lowers_to_the_band_around_the_radius() {
780 let params = draw_shape_params_for_primitive(
781 DrawPrimitive::Arc {
782 rect: Rect {
783 x: 0.0,
784 y: 0.0,
785 width: 60.0,
786 height: 60.0,
787 },
788 brush: Brush::solid(Color::WHITE),
789 center: Point::new(30.0, 30.0),
790 radius: 20.0,
791 start_angle: 0.0,
792 sweep_angle: 1.0,
793 stroke: Some(Stroke::new(8.0).with_cap(StrokeCap::Round)),
794 inner_radius: 0.0,
795 },
796 Rect::from_size(cranpose_ui_graphics::Size {
797 width: 60.0,
798 height: 60.0,
799 }),
800 &GraphicsLayer::default(),
801 None,
802 BlendMode::SrcOver,
803 )
804 .expect("stroked arc");
805
806 let arc = params.arc.expect("arc geometry");
807 assert_eq!(arc.inner_radius, 16.0);
808 assert_eq!(arc.outer_radius, 24.0);
809 assert_eq!(arc.cap, StrokeCap::Round);
810 assert!(
811 params.stroke.is_none(),
812 "an arc carries its width in the band radii, not in `stroke`"
813 );
814 }
815
816 #[test]
817 fn arc_radii_and_center_follow_the_layer_transform() {
818 let layer = GraphicsLayer {
819 scale: 3.0,
820 transform_origin: cranpose_ui_graphics::TransformOrigin::new(0.0, 0.0),
821 ..Default::default()
822 };
823 let params = draw_shape_params_for_primitive(
824 DrawPrimitive::Arc {
825 rect: Rect {
826 x: 0.0,
827 y: 0.0,
828 width: 20.0,
829 height: 20.0,
830 },
831 brush: Brush::solid(Color::WHITE),
832 center: Point::new(10.0, 10.0),
833 radius: 10.0,
834 start_angle: 0.0,
835 sweep_angle: 1.0,
836 stroke: None,
837 inner_radius: 4.0,
838 },
839 Rect {
840 x: 0.0,
841 y: 0.0,
842 width: 20.0,
843 height: 20.0,
844 },
845 &layer,
846 None,
847 BlendMode::SrcOver,
848 )
849 .expect("arc");
850
851 let arc = params.arc.expect("arc geometry");
852 assert_eq!(arc.center, Point::new(30.0, 30.0));
853 assert_eq!(arc.inner_radius, 12.0);
854 assert_eq!(arc.outer_radius, 30.0);
855 assert_eq!(
857 params.local_rect,
858 Rect {
859 x: 0.0,
860 y: 0.0,
861 width: 60.0,
862 height: 60.0,
863 }
864 );
865 }
866
867 #[test]
868 fn degenerate_arcs_emit_nothing() {
869 let base_rect = Rect::from_size(cranpose_ui_graphics::Size {
870 width: 20.0,
871 height: 20.0,
872 });
873 let cases: [(f32, f32, f32, Option<Stroke>); 4] = [
874 (10.0, 10.0, 1.0, None),
876 (10.0, 0.0, 0.0, None),
878 (0.0, 0.0, 1.0, None),
880 (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 cranpose_ui_graphics::{
934 FontWeight as DrawFontWeight, TextPrimitive, TextStyle as DrawTextStyle,
935 };
936 use std::rc::Rc as StdRc;
937
938 #[derive(Default)]
939 struct CollectingTextSink {
940 texts: Vec<TextDrawParams>,
941 }
942
943 impl DrawPrimitiveSink for CollectingTextSink {
944 fn push_shape(&mut self, _params: ShapeDrawParams) {}
945 fn push_image(&mut self, _params: ImageDrawParams) {}
946 fn push_shadow(
947 &mut self,
948 _shadow_primitive: ShadowPrimitive,
949 _layer_bounds: Rect,
950 _layer: &GraphicsLayer,
951 _clip: Option<Rect>,
952 ) {
953 }
954 fn push_text(&mut self, params: TextDrawParams) {
955 self.texts.push(params);
956 }
957 }
958
959 fn text_primitive(rect: Rect, text: &str, style: DrawTextStyle) -> DrawPrimitive {
960 DrawPrimitive::Text(Box::new(TextPrimitive {
961 rect,
962 text: StdRc::from(text),
963 style,
964 color: Color::WHITE,
965 }))
966 }
967
968 fn lower_text(primitive: DrawPrimitive, layer: &GraphicsLayer) -> Vec<TextDrawParams> {
969 let mut sink = CollectingTextSink::default();
970 emit_draw_primitive(
971 &primitive,
972 layer_bounds(),
973 layer,
974 None,
975 &mut sink,
976 None,
977 false,
978 );
979 sink.texts
980 }
981
982 #[test]
983 fn text_lowers_into_the_layer_translated_block_the_scope_measured() {
984 let params = lower_text(
985 text_primitive(
986 Rect {
987 x: 5.0,
988 y: 6.0,
989 width: 40.0,
990 height: 18.0,
991 },
992 "SCORE",
993 DrawTextStyle::new(12.0),
994 ),
995 &GraphicsLayer::default(),
996 );
997 assert_eq!(params.len(), 1);
998 assert_eq!(
1000 params[0].rect,
1001 Rect {
1002 x: 15.0,
1003 y: 26.0,
1004 width: 40.0,
1005 height: 18.0,
1006 }
1007 );
1008 assert_eq!(params[0].text.text, "SCORE");
1009 assert_eq!(params[0].font_size, 12.0);
1010 assert_eq!(params[0].scale, 1.0);
1011 assert_eq!(params[0].color, Color::WHITE);
1012 }
1013
1014 #[test]
1015 fn text_lowering_carries_the_uniform_layer_scale_for_rasterization() {
1016 let layer = GraphicsLayer {
1017 scale: 2.0,
1018 transform_origin: cranpose_ui_graphics::TransformOrigin::new(0.0, 0.0),
1019 ..Default::default()
1020 };
1021 let params = lower_text(
1022 text_primitive(
1023 Rect {
1024 x: 0.0,
1025 y: 0.0,
1026 width: 30.0,
1027 height: 14.0,
1028 },
1029 "AB",
1030 DrawTextStyle::new(10.0),
1031 ),
1032 &layer,
1033 );
1034 assert_eq!(params[0].scale, 2.0, "glyphs rasterize at the layer scale");
1035 assert!(approx(params[0].rect.width, 60.0), "{:?}", params[0].rect);
1036 }
1037
1038 #[test]
1039 fn text_lowering_folds_the_layer_alpha_into_the_glyph_color() {
1040 let layer = GraphicsLayer {
1041 alpha: 0.5,
1042 ..Default::default()
1043 };
1044 let params = lower_text(
1045 text_primitive(
1046 Rect {
1047 x: 0.0,
1048 y: 0.0,
1049 width: 30.0,
1050 height: 14.0,
1051 },
1052 "AB",
1053 DrawTextStyle::new(10.0),
1054 ),
1055 &layer,
1056 );
1057 assert!(approx(params[0].color.3, 0.5));
1058 }
1059
1060 #[test]
1061 fn text_lowering_uses_the_same_style_translation_the_draw_scope_measured_with() {
1062 let style = DrawTextStyle::new(18.0)
1063 .with_font_family("Fira Sans")
1064 .with_weight(DrawFontWeight::BOLD);
1065 let params = lower_text(
1066 text_primitive(
1067 Rect {
1068 x: 0.0,
1069 y: 0.0,
1070 width: 30.0,
1071 height: 20.0,
1072 },
1073 "AB",
1074 style.clone(),
1075 ),
1076 &GraphicsLayer::default(),
1077 );
1078 assert_eq!(params[0].text_style, text_style_for_draw_style(&style));
1079 }
1080
1081 #[test]
1082 fn lowered_text_is_never_re_wrapped_against_the_box_it_was_measured_into() {
1083 let params = lower_text(
1086 text_primitive(
1087 Rect {
1088 x: 0.0,
1089 y: 0.0,
1090 width: 4.0,
1091 height: 14.0,
1092 },
1093 "a very long line",
1094 DrawTextStyle::new(10.0),
1095 ),
1096 &GraphicsLayer::default(),
1097 );
1098 assert!(!params[0].layout_options.soft_wrap);
1099 assert_eq!(params[0].layout_options.overflow, TextOverflow::Visible);
1100 }
1101
1102 #[test]
1103 fn degenerate_text_never_reaches_a_sink() {
1104 let invisible_layer = GraphicsLayer {
1105 alpha: 0.0,
1106 ..Default::default()
1107 };
1108 let cases: [(DrawPrimitive, GraphicsLayer); 3] = [
1109 (
1110 text_primitive(
1111 Rect {
1112 x: 0.0,
1113 y: 0.0,
1114 width: 20.0,
1115 height: 10.0,
1116 },
1117 "",
1118 DrawTextStyle::new(10.0),
1119 ),
1120 GraphicsLayer::default(),
1121 ),
1122 (
1123 text_primitive(
1124 Rect {
1125 x: 0.0,
1126 y: 0.0,
1127 width: 0.0,
1128 height: 10.0,
1129 },
1130 "AB",
1131 DrawTextStyle::new(10.0),
1132 ),
1133 GraphicsLayer::default(),
1134 ),
1135 (
1136 text_primitive(
1137 Rect {
1138 x: 0.0,
1139 y: 0.0,
1140 width: 20.0,
1141 height: 10.0,
1142 },
1143 "AB",
1144 DrawTextStyle::new(10.0),
1145 ),
1146 invisible_layer,
1147 ),
1148 ];
1149 for (primitive, layer) in cases {
1150 assert!(
1151 lower_text(primitive, &layer).is_empty(),
1152 "degenerate text must not reach the renderer"
1153 );
1154 }
1155 }
1156
1157 #[test]
1158 fn blended_text_still_lowers_because_glyphs_composite_src_over() {
1159 let params = lower_text(
1162 DrawPrimitive::Blend {
1163 primitive: Box::new(text_primitive(
1164 Rect {
1165 x: 0.0,
1166 y: 0.0,
1167 width: 20.0,
1168 height: 10.0,
1169 },
1170 "AB",
1171 DrawTextStyle::new(10.0),
1172 )),
1173 blend_mode: BlendMode::DstOut,
1174 },
1175 &GraphicsLayer::default(),
1176 );
1177 assert_eq!(params.len(), 1);
1178 }
1179}