1#[cfg(test)]
2use cranpose_render_common::text_measure::{
3 fallback_char_width, fallback_cursor_x_for_byte_offset, fallback_line_height,
4 fallback_text_metrics,
5};
6use cranpose_render_common::{
7 brush_sampling::sample_brush_rgba, graph_scene::RenderDiagnostics, shape_sdf,
8 software_text_raster::rasterize_annotated_text_region, text_measure::SoftwareTextResources,
9};
10use cranpose_ui::text::TextMotion;
11use cranpose_ui_graphics::{
12 ArcGeometry, BlendMode, ColorFilter, CornerRadii, LineGeometry, Point, Rect, StrokeJoin,
13 Trapezoid, expand_rect,
14};
15
16use crate::{
17 pipeline,
18 scene::{ImageDraw, RasterScene, Scene, TextDraw},
19 style::point_in_resolved_rounded_rect,
20};
21
22fn is_blend_mode_supported(mode: BlendMode) -> bool {
23 matches!(mode, BlendMode::SrcOver | BlendMode::DstOut)
24}
25
26fn snapped_anchor_origin(anchor: Point) -> Point {
27 Point::new(anchor.x.round(), anchor.y.round())
28}
29
30fn snap_delta_for_anchor(anchor: Point) -> Point {
31 let snapped = snapped_anchor_origin(anchor);
32 Point::new(snapped.x - anchor.x, snapped.y - anchor.y)
33}
34
35#[derive(Clone, Copy)]
36struct ClipBounds {
37 min_x: i32,
38 min_y: i32,
39 max_x: i32,
40 max_y: i32,
41}
42
43fn clip_rect_to_bounds(
44 rect: Rect,
45 clip: Option<Rect>,
46 width: u32,
47 height: u32,
48) -> Option<ClipBounds> {
49 let mut min_x = rect.x;
50 let mut min_y = rect.y;
51 let mut max_x = rect.x + rect.width;
52 let mut max_y = rect.y + rect.height;
53
54 if let Some(clip_rect) = clip {
55 min_x = min_x.max(clip_rect.x);
56 min_y = min_y.max(clip_rect.y);
57 max_x = max_x.min(clip_rect.x + clip_rect.width);
58 max_y = max_y.min(clip_rect.y + clip_rect.height);
59 }
60
61 min_x = min_x.max(0.0);
62 min_y = min_y.max(0.0);
63 max_x = max_x.min(width as f32);
64 max_y = max_y.min(height as f32);
65
66 if max_x <= min_x || max_y <= min_y {
67 return None;
68 }
69
70 let min_x = min_x.floor() as i32;
71 let min_y = min_y.floor() as i32;
72 let max_x = max_x.ceil() as i32;
73 let max_y = max_y.ceil() as i32;
74
75 let min_x = min_x.clamp(0, width as i32);
76 let min_y = min_y.clamp(0, height as i32);
77 let max_x = max_x.clamp(0, width as i32);
78 let max_y = max_y.clamp(0, height as i32);
79
80 if min_x >= max_x || min_y >= max_y {
81 return None;
82 }
83
84 Some(ClipBounds {
85 min_x,
86 min_y,
87 max_x,
88 max_y,
89 })
90}
91
92pub fn draw_scene(frame: &mut [u8], width: u32, height: u32, scene: &Scene) {
93 let text_resources = SoftwareTextResources::default();
94 draw_scene_with_text_resources(frame, width, height, scene, &text_resources);
95}
96
97pub fn draw_scene_with_text_resources(
98 frame: &mut [u8],
99 width: u32,
100 height: u32,
101 scene: &Scene,
102 text_resources: &SoftwareTextResources,
103) {
104 draw_scene_scaled(frame, width, height, scene, text_resources, 1.0);
105}
106
107pub(crate) fn draw_scene_scaled(
108 frame: &mut [u8],
109 width: u32,
110 height: u32,
111 scene: &Scene,
112 text_resources: &SoftwareTextResources,
113 scale: f32,
114) {
115 if let Some(graph) = scene.graph.as_ref() {
116 let raster_scene = pipeline::build_raster_scene(graph, scene.diagnostics(), scale);
117 draw_raster_scene(
118 frame,
119 width,
120 height,
121 &raster_scene,
122 scene.diagnostics(),
123 text_resources,
124 );
125 } else {
126 clear_frame(frame);
127 }
128}
129
130fn clear_frame(frame: &mut [u8]) {
131 for chunk in frame.as_chunks_mut::<4>().0 {
132 chunk.copy_from_slice(&[18, 18, 24, 255]);
133 }
134}
135
136fn draw_raster_scene(
137 frame: &mut [u8],
138 width: u32,
139 height: u32,
140 scene: &RasterScene,
141 diagnostics: &RenderDiagnostics,
142 text_resources: &SoftwareTextResources,
143) {
144 clear_frame(frame);
145 let mut ordered_items =
146 Vec::with_capacity(scene.shapes.len() + scene.images.len() + scene.texts.len());
147 for (index, shape) in scene.shapes.iter().enumerate() {
148 ordered_items.push((shape.z_index, RenderItem::Shape(index)));
149 }
150 for (index, image) in scene.images.iter().enumerate() {
151 ordered_items.push((image.z_index, RenderItem::Image(index)));
152 }
153 for (index, text) in scene.texts.iter().enumerate() {
154 ordered_items.push((text.z_index, RenderItem::Text(index)));
155 }
156 for (index, shadow) in scene.shadows.iter().enumerate() {
157 ordered_items.push((shadow.z_index, RenderItem::Shadow(index)));
158 }
159 ordered_items.sort_unstable_by_key(|(z, _)| *z);
160
161 for (_, item) in ordered_items {
162 match item {
163 RenderItem::Shape(index) => {
164 draw_shape(frame, width, height, &scene.shapes[index], diagnostics);
165 }
166 RenderItem::Image(index) => {
167 draw_image(frame, width, height, &scene.images[index], diagnostics);
168 }
169 RenderItem::Shadow(index) => {
170 draw_shadow(frame, width, height, &scene.shadows[index], diagnostics);
171 }
172 RenderItem::Text(index) => {
173 draw_text(
174 frame,
175 width,
176 height,
177 &scene.texts[index],
178 diagnostics,
179 text_resources,
180 );
181 }
182 }
183 }
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187enum RenderItem {
188 Shape(usize),
189 Image(usize),
190 Text(usize),
191 Shadow(usize),
192}
193
194fn draw_shadow(
197 frame: &mut [u8],
198 width: u32,
199 height: u32,
200 draw: &crate::scene::ShadowDraw,
201 diagnostics: &RenderDiagnostics,
202) {
203 let snap_delta = draw
204 .snap_anchor
205 .map(snap_delta_for_anchor)
206 .unwrap_or_default();
207 let shadow = draw.shadow.translated(snap_delta.x, snap_delta.y);
208 let clip = draw
209 .clip
210 .map(|clip| clip.translate(snap_delta.x, snap_delta.y));
211 let Some(bounds) = clip_rect_to_bounds(shadow.bounds, clip, width, height) else {
212 return;
213 };
214 let color = [
215 draw.color.r(),
216 draw.color.g(),
217 draw.color.b(),
218 draw.color.a(),
219 ];
220 for py in bounds.min_y.max(0)..bounds.max_y.min(height as i32) {
221 for px in bounds.min_x.max(0)..bounds.max_x.min(width as i32) {
222 let coverage = shadow.coverage(px as f32 + 0.5, py as f32 + 0.5);
223 if coverage <= 0.0 {
224 continue;
225 }
226 let idx = ((py as u32 * width + px as u32) * 4) as usize;
227 blend_pixel(
228 &mut frame[idx..idx + 4],
229 [color[0], color[1], color[2], color[3] * coverage],
230 BlendMode::SrcOver,
231 diagnostics,
232 );
233 }
234 }
235}
236
237fn draw_shape(
238 frame: &mut [u8],
239 width: u32,
240 height: u32,
241 draw: &crate::scene::DrawShape,
242 diagnostics: &RenderDiagnostics,
243) {
244 let snap_delta = draw
245 .snap_anchor
246 .map(snap_delta_for_anchor)
247 .unwrap_or_default();
248 let rect = draw.rect.translate(snap_delta.x, snap_delta.y);
249 let clip = draw.clip;
250 let dither_origin = draw
251 .snap_anchor
252 .map(snapped_anchor_origin)
253 .unwrap_or_default();
254 let rect = if draw.snap_to_pixel_grid {
255 Rect {
256 x: rect.x.round(),
257 y: rect.y.round(),
258 width: if rect.width > 0.0 {
259 rect.width.ceil().max(1.0)
260 } else {
261 rect.width
262 },
263 height: if rect.height > 0.0 {
264 rect.height.ceil().max(1.0)
265 } else {
266 rect.height
267 },
268 }
269 } else {
270 rect
271 };
272 let Some(coverage) = ShapeCoverage::of(draw, rect, snap_delta) else {
273 return;
274 };
275 let Some(clip_bounds) = clip_rect_to_bounds(coverage.reach(rect), clip, width, height) else {
276 return;
277 };
278
279 for py in clip_bounds.min_y..clip_bounds.max_y {
280 if py < 0 || py >= height as i32 {
281 continue;
282 }
283 for px in clip_bounds.min_x..clip_bounds.max_x {
284 if px < 0 || px >= width as i32 {
285 continue;
286 }
287 let center_x = px as f32 + 0.5;
288 let center_y = py as f32 + 0.5;
289 let coverage = coverage.at(Point::new(center_x, center_y));
290 if coverage <= 0.0 {
291 continue;
292 }
293
294 let mut sample =
295 sample_brush_rgba(&draw.brush, rect, center_x, center_y, dither_origin);
296 sample[3] *= coverage;
297 let alpha = sample[3];
298 if alpha <= 0.0 {
299 continue;
300 }
301 let idx = ((py as u32 * width + px as u32) * 4) as usize;
302 blend_pixel(
303 &mut frame[idx..idx + 4],
304 sample,
305 draw.blend_mode,
306 diagnostics,
307 );
308 }
309 }
310}
311
312enum ShapeCoverage {
315 Line(LineGeometry),
316 Trapezoid(Trapezoid),
317 Arc(ArcGeometry),
318 Stroke {
319 rect: Rect,
320 radii: Option<CornerRadii>,
321 half_width: f32,
322 join: StrokeJoin,
323 },
324 Fill {
325 rect: Rect,
326 radii: Option<CornerRadii>,
327 },
328}
329
330impl ShapeCoverage {
331 fn of(draw: &crate::scene::DrawShape, rect: Rect, snap_delta: Point) -> Option<Self> {
334 let shift = |point: Point| Point::new(point.x + snap_delta.x, point.y + snap_delta.y);
335 if let Some(line) = draw.line {
336 return (!line.is_degenerate())
337 .then(|| Self::Line(line.placed(shift(line.start), shift(line.end), 1.0)));
338 }
339 if let Some(trapezoid) = draw.trapezoid {
340 return Some(Self::Trapezoid(
341 trapezoid.translate(snap_delta.x, snap_delta.y),
342 ));
343 }
344 if let Some(arc) = draw.arc {
345 return (!arc.is_degenerate()).then(|| {
346 Self::Arc(ArcGeometry {
347 center: shift(arc.center),
348 ..arc
349 })
350 });
351 }
352 let outset = draw.stroke.map_or(0.0, |stroke| stroke.half_width());
353 let radii = draw.shape.map(|shape| {
354 shape.resolve(
355 (rect.width - outset * 2.0).max(0.0),
356 (rect.height - outset * 2.0).max(0.0),
357 )
358 });
359 match draw.stroke {
360 Some(stroke) if !stroke.is_visible() => None,
361 Some(stroke) => Some(Self::Stroke {
362 rect,
363 radii,
364 half_width: stroke.half_width(),
365 join: stroke.join,
366 }),
367 None => Some(Self::Fill { rect, radii }),
368 }
369 }
370
371 fn reach(&self, rect: Rect) -> Rect {
374 match self {
375 Self::Trapezoid(trapezoid) => expand_rect(trapezoid.bounds(), 0.5),
376 _ => rect,
377 }
378 }
379
380 fn at(&self, point: Point) -> f32 {
381 match self {
382 Self::Line(line) => line.coverage(point),
383 Self::Trapezoid(trapezoid) => trapezoid.coverage(point),
384 Self::Arc(arc) => shape_sdf::arc_coverage(point, arc),
385 Self::Stroke {
386 rect,
387 radii,
388 half_width,
389 join,
390 } => shape_sdf::stroked_rect_coverage(point, *rect, *radii, *half_width, *join),
391 Self::Fill { rect, radii } => match radii {
392 Some(radii) if !point_in_resolved_rounded_rect(point.x, point.y, *rect, radii) => {
393 0.0
394 }
395 _ => 1.0,
396 },
397 }
398 }
399}
400
401fn draw_image(
402 frame: &mut [u8],
403 width: u32,
404 height: u32,
405 draw: &ImageDraw,
406 diagnostics: &RenderDiagnostics,
407) {
408 let snap_delta = draw
409 .snap_anchor
410 .map(snap_delta_for_anchor)
411 .unwrap_or_default();
412 let rect = draw.rect.translate(snap_delta.x, snap_delta.y);
413 let clip = draw.clip;
414
415 if draw.alpha <= 0.0 || rect.width <= 0.0 || rect.height <= 0.0 {
416 return;
417 }
418
419 let Some(clip_bounds) = clip_rect_to_bounds(rect, clip, width, height) else {
420 return;
421 };
422
423 let img_width = draw.image.width();
424 let img_height = draw.image.height();
425 if img_width == 0 || img_height == 0 {
426 return;
427 }
428
429 let (sr_x, sr_y, sr_w, sr_h) = if let Some(sr) = draw.src_rect {
430 (sr.x, sr.y, sr.width, sr.height)
431 } else {
432 (0.0, 0.0, img_width as f32, img_height as f32)
433 };
434
435 for py in clip_bounds.min_y..clip_bounds.max_y {
436 for px in clip_bounds.min_x..clip_bounds.max_x {
437 let sample_x = px as f32 + 0.5;
438 let sample_y = py as f32 + 0.5;
439 let u = ((sample_x - rect.x) / rect.width).clamp(0.0, 1.0);
440 let v = ((sample_y - rect.y) / rect.height).clamp(0.0, 1.0);
441
442 let mut sample = match draw.sampling {
443 cranpose_ui_graphics::ImageSampling::Nearest => {
444 let src_x = ((sr_x + u * sr_w).floor() as i32).clamp(0, img_width as i32 - 1);
445 let src_y = ((sr_y + v * sr_h).floor() as i32).clamp(0, img_height as i32 - 1);
446 sample_image_nearest(&draw.image, src_x as u32, src_y as u32)
447 }
448 cranpose_ui_graphics::ImageSampling::Linear => {
449 sample_image_linear(&draw.image, sr_x + u * sr_w - 0.5, sr_y + v * sr_h - 0.5)
450 }
451 };
452
453 if let Some(filter) = draw.color_filter {
454 sample = apply_color_filter(sample, filter);
455 }
456
457 sample[3] *= draw.alpha.clamp(0.0, 1.0);
458 if sample[3] <= 0.0 {
459 continue;
460 }
461
462 let dst_idx = ((py as u32 * width + px as u32) * 4) as usize;
463 blend_pixel(
464 &mut frame[dst_idx..dst_idx + 4],
465 sample,
466 draw.blend_mode,
467 diagnostics,
468 );
469 }
470 }
471}
472
473fn sample_image_nearest(
474 image: &cranpose_ui_graphics::ImageBitmap,
475 src_x: u32,
476 src_y: u32,
477) -> [f32; 4] {
478 image
479 .rgba8_pixel(src_y as usize * image.width() as usize + src_x as usize)
480 .map(|channel| channel as f32 / 255.0)
481}
482
483fn sample_image_linear(image: &cranpose_ui_graphics::ImageBitmap, x: f32, y: f32) -> [f32; 4] {
484 let img_width = image.width();
485 let img_height = image.height();
486 let x = x.clamp(0.0, img_width.saturating_sub(1) as f32);
487 let y = y.clamp(0.0, img_height.saturating_sub(1) as f32);
488 let x0 = x.floor();
489 let y0 = y.floor();
490 let tx = x - x0;
491 let ty = y - y0;
492 let x0 = (x0 as i32).clamp(0, img_width as i32 - 1) as u32;
493 let y0 = (y0 as i32).clamp(0, img_height as i32 - 1) as u32;
494 let x1 = (x0 + 1).min(img_width - 1);
495 let y1 = (y0 + 1).min(img_height - 1);
496 let top_left = sample_image_nearest(image, x0, y0);
497 let top_right = sample_image_nearest(image, x1, y0);
498 let bottom_left = sample_image_nearest(image, x0, y1);
499 let bottom_right = sample_image_nearest(image, x1, y1);
500
501 let mut out = [0.0; 4];
502 for channel in 0..4 {
503 let top = top_left[channel] + (top_right[channel] - top_left[channel]) * tx;
504 let bottom = bottom_left[channel] + (bottom_right[channel] - bottom_left[channel]) * tx;
505 out[channel] = top + (bottom - top) * ty;
506 }
507 out
508}
509
510fn draw_text(
511 frame: &mut [u8],
512 width: u32,
513 height: u32,
514 draw: &TextDraw,
515 diagnostics: &RenderDiagnostics,
516 text_resources: &SoftwareTextResources,
517) {
518 let text_scale = draw.scale.max(0.0);
519 if text_scale == 0.0 {
520 return;
521 }
522 let static_text_motion = draw
523 .text_style
524 .paragraph_style
525 .text_motion
526 .unwrap_or(TextMotion::Static)
527 == TextMotion::Static;
528 let snap_delta = if static_text_motion {
529 draw.snap_anchor
530 .map(snap_delta_for_anchor)
531 .unwrap_or_default()
532 } else {
533 Point::default()
534 };
535 let rect = draw.rect.translate(snap_delta.x, snap_delta.y);
536 let raster_rect = if static_text_motion {
537 Rect {
538 x: rect.x.round(),
539 y: rect.y.round(),
540 width: if rect.width > 0.0 {
541 rect.width.ceil().max(1.0)
542 } else {
543 rect.width
544 },
545 height: if rect.height > 0.0 {
546 rect.height.ceil().max(1.0)
547 } else {
548 rect.height
549 },
550 }
551 } else {
552 rect
553 };
554 let image = rasterize_annotated_text_region(
555 draw.text.as_ref(),
556 raster_rect,
557 Point::new(raster_rect.x, raster_rect.y),
558 &draw.text_style,
559 draw.color,
560 draw.font_size,
561 text_scale,
562 text_resources.fonts(),
563 None,
564 );
565 let Some(image) = image else {
566 return;
567 };
568 let blit_origin = if static_text_motion {
569 Point::new(raster_rect.x, raster_rect.y)
570 } else {
571 Point::new(rect.x, rect.y)
572 };
573 let blit_rect = Rect {
574 x: blit_origin.x,
575 y: blit_origin.y,
576 width: image.width() as f32,
577 height: image.height() as f32,
578 };
579 blit_rasterized_text_image(
580 frame,
581 width,
582 height,
583 blit_rect,
584 draw.clip,
585 &image,
586 diagnostics,
587 );
588}
589
590fn blit_rasterized_text_image(
591 frame: &mut [u8],
592 width: u32,
593 height: u32,
594 rect: Rect,
595 clip: Option<Rect>,
596 image: &cranpose_ui_graphics::ImageBitmap,
597 diagnostics: &RenderDiagnostics,
598) {
599 if rect.width <= 0.0 || rect.height <= 0.0 {
600 return;
601 }
602 let Some(clip_bounds) = clip_rect_to_bounds(rect, clip, width, height) else {
603 return;
604 };
605
606 let img_width = image.width();
607 let img_height = image.height();
608 if img_width == 0 || img_height == 0 {
609 return;
610 }
611
612 for py in clip_bounds.min_y..clip_bounds.max_y {
613 for px in clip_bounds.min_x..clip_bounds.max_x {
614 let sample_x = px as f32 + 0.5;
615 let sample_y = py as f32 + 0.5;
616 let u = ((sample_x - rect.x) / rect.width).clamp(0.0, 1.0);
617 let v = ((sample_y - rect.y) / rect.height).clamp(0.0, 1.0);
618
619 let src = sample_image_linear(
620 image,
621 u * img_width.saturating_sub(1) as f32,
622 v * img_height.saturating_sub(1) as f32,
623 );
624 if src[3] <= 0.0 {
625 continue;
626 }
627
628 let dst_idx = ((py as u32 * width + px as u32) * 4) as usize;
629 blend_pixel(
630 &mut frame[dst_idx..dst_idx + 4],
631 src,
632 BlendMode::SrcOver,
633 diagnostics,
634 );
635 }
636 }
637}
638
639fn blend_pixel(
640 dst: &mut [u8],
641 src: [f32; 4],
642 blend_mode: BlendMode,
643 diagnostics: &RenderDiagnostics,
644) {
645 let resolved_blend_mode = if is_blend_mode_supported(blend_mode) {
646 blend_mode
647 } else {
648 if diagnostics.claim_warning_once("pixels.unsupported-blend-mode") {
649 log::warn!(
650 "Pixels renderer currently supports BlendMode::SrcOver and BlendMode::DstOut; falling back to SrcOver for unsupported modes"
651 );
652 }
653 BlendMode::SrcOver
654 };
655
656 let src_alpha = src[3].clamp(0.0, 1.0);
657 if src_alpha <= 0.0 {
658 return;
659 }
660 let dst_r = dst[0] as f32 / 255.0;
661 let dst_g = dst[1] as f32 / 255.0;
662 let dst_b = dst[2] as f32 / 255.0;
663 let dst_a = dst[3] as f32 / 255.0;
664
665 let (out_r, out_g, out_b, out_a) = match resolved_blend_mode {
666 BlendMode::DstOut => {
667 let keep = 1.0 - src_alpha;
668 (dst_r * keep, dst_g * keep, dst_b * keep, dst_a * keep)
669 }
670 BlendMode::SrcOver => (
671 src[0].clamp(0.0, 1.0) * src_alpha + dst_r * (1.0 - src_alpha),
672 src[1].clamp(0.0, 1.0) * src_alpha + dst_g * (1.0 - src_alpha),
673 src[2].clamp(0.0, 1.0) * src_alpha + dst_b * (1.0 - src_alpha),
674 src_alpha + dst_a * (1.0 - src_alpha),
675 ),
676 _ => (
677 src[0].clamp(0.0, 1.0) * src_alpha + dst_r * (1.0 - src_alpha),
678 src[1].clamp(0.0, 1.0) * src_alpha + dst_g * (1.0 - src_alpha),
679 src[2].clamp(0.0, 1.0) * src_alpha + dst_b * (1.0 - src_alpha),
680 src_alpha + dst_a * (1.0 - src_alpha),
681 ),
682 };
683
684 dst[0] = (out_r.clamp(0.0, 1.0) * 255.0).round() as u8;
685 dst[1] = (out_g.clamp(0.0, 1.0) * 255.0).round() as u8;
686 dst[2] = (out_b.clamp(0.0, 1.0) * 255.0).round() as u8;
687 dst[3] = (out_a.clamp(0.0, 1.0) * 255.0).round() as u8;
688}
689
690fn apply_color_filter(sample: [f32; 4], filter: ColorFilter) -> [f32; 4] {
691 filter.apply_rgba(sample)
692}
693
694#[cfg(test)]
695#[path = "tests/draw_tests.rs"]
696mod tests;