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