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