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