1use std::rc::Rc;
2
3use cranpose_render_common::brush_sampling::sample_brush_rgba;
4use cranpose_render_common::graph_scene::RenderDiagnostics;
5use cranpose_render_common::shape_sdf;
6use cranpose_render_common::software_text_raster::rasterize_text_to_image;
7use cranpose_render_common::text_measure::SoftwareTextResources;
8#[cfg(test)]
9use cranpose_render_common::text_measure::{
10 fallback_char_width, fallback_cursor_x_for_byte_offset, fallback_line_height,
11 fallback_text_metrics,
12};
13use cranpose_ui::text::TextMotion;
14use cranpose_ui_graphics::{BlendMode, ColorFilter, Point, Rect};
15
16use crate::pipeline;
17use crate::scene::{ImageDraw, RasterScene, Scene, TextDraw};
18use crate::style::point_in_resolved_rounded_rect;
19
20fn is_blend_mode_supported(mode: BlendMode) -> bool {
21 matches!(mode, BlendMode::SrcOver | BlendMode::DstOut)
22}
23
24fn snap_delta_for_anchor(anchor: Point) -> Point {
25 Point::new(anchor.x.round() - anchor.x, anchor.y.round() - anchor.y)
26}
27
28#[derive(Clone, Copy)]
29struct ClipBounds {
30 min_x: i32,
31 min_y: i32,
32 max_x: i32,
33 max_y: i32,
34}
35
36fn clip_rect_to_bounds(
37 rect: Rect,
38 clip: Option<Rect>,
39 width: u32,
40 height: u32,
41) -> Option<ClipBounds> {
42 let mut min_x = rect.x;
43 let mut min_y = rect.y;
44 let mut max_x = rect.x + rect.width;
45 let mut max_y = rect.y + rect.height;
46
47 if let Some(clip_rect) = clip {
48 min_x = min_x.max(clip_rect.x);
49 min_y = min_y.max(clip_rect.y);
50 max_x = max_x.min(clip_rect.x + clip_rect.width);
51 max_y = max_y.min(clip_rect.y + clip_rect.height);
52 }
53
54 min_x = min_x.max(0.0);
55 min_y = min_y.max(0.0);
56 max_x = max_x.min(width as f32);
57 max_y = max_y.min(height as f32);
58
59 if max_x <= min_x || max_y <= min_y {
60 return None;
61 }
62
63 let min_x = min_x.floor() as i32;
64 let min_y = min_y.floor() as i32;
65 let max_x = max_x.ceil() as i32;
66 let max_y = max_y.ceil() as i32;
67
68 let min_x = min_x.clamp(0, width as i32);
69 let min_y = min_y.clamp(0, height as i32);
70 let max_x = max_x.clamp(0, width as i32);
71 let max_y = max_y.clamp(0, height as i32);
72
73 if min_x >= max_x || min_y >= max_y {
74 return None;
75 }
76
77 Some(ClipBounds {
78 min_x,
79 min_y,
80 max_x,
81 max_y,
82 })
83}
84
85pub fn draw_scene(frame: &mut [u8], width: u32, height: u32, scene: &Scene) {
86 let text_resources = SoftwareTextResources::default();
87 draw_scene_with_text_resources(frame, width, height, scene, &text_resources);
88}
89
90pub fn draw_scene_with_text_resources(
91 frame: &mut [u8],
92 width: u32,
93 height: u32,
94 scene: &Scene,
95 text_resources: &SoftwareTextResources,
96) {
97 if let Some(graph) = scene.graph.as_ref() {
98 let raster_scene = pipeline::build_raster_scene(graph, scene.diagnostics());
99 draw_raster_scene(
100 frame,
101 width,
102 height,
103 &raster_scene,
104 scene.diagnostics(),
105 text_resources,
106 );
107 } else {
108 clear_frame(frame);
109 }
110}
111
112fn clear_frame(frame: &mut [u8]) {
113 for chunk in frame.chunks_exact_mut(4) {
114 chunk.copy_from_slice(&[18, 18, 24, 255]);
115 }
116}
117
118fn draw_raster_scene(
119 frame: &mut [u8],
120 width: u32,
121 height: u32,
122 scene: &RasterScene,
123 diagnostics: &RenderDiagnostics,
124 text_resources: &SoftwareTextResources,
125) {
126 clear_frame(frame);
127 let mut ordered_items =
128 Vec::with_capacity(scene.shapes.len() + scene.images.len() + scene.texts.len());
129 for (index, shape) in scene.shapes.iter().enumerate() {
130 ordered_items.push((shape.z_index, RenderItem::Shape(index)));
131 }
132 for (index, image) in scene.images.iter().enumerate() {
133 ordered_items.push((image.z_index, RenderItem::Image(index)));
134 }
135 for (index, text) in scene.texts.iter().enumerate() {
136 ordered_items.push((text.z_index, RenderItem::Text(index)));
137 }
138 ordered_items.sort_unstable_by_key(|(z, _)| *z);
141
142 for (_, item) in ordered_items {
143 match item {
144 RenderItem::Shape(index) => {
145 draw_shape(frame, width, height, &scene.shapes[index], diagnostics);
146 }
147 RenderItem::Image(index) => {
148 draw_image(frame, width, height, &scene.images[index], diagnostics);
149 }
150 RenderItem::Text(index) => {
151 draw_text(
152 frame,
153 width,
154 height,
155 &scene.texts[index],
156 diagnostics,
157 text_resources,
158 );
159 }
160 }
161 }
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165enum RenderItem {
166 Shape(usize),
167 Image(usize),
168 Text(usize),
169}
170
171fn draw_shape(
172 frame: &mut [u8],
173 width: u32,
174 height: u32,
175 draw: &crate::scene::DrawShape,
176 diagnostics: &RenderDiagnostics,
177) {
178 let snap_delta = draw
179 .snap_anchor
180 .map(snap_delta_for_anchor)
181 .unwrap_or_default();
182 let rect = draw.rect.translate(snap_delta.x, snap_delta.y);
183 let clip = draw.clip;
186 let rect = if draw.snap_to_pixel_grid {
187 Rect {
188 x: rect.x.round(),
189 y: rect.y.round(),
190 width: if rect.width > 0.0 {
191 rect.width.ceil().max(1.0)
192 } else {
193 rect.width
194 },
195 height: if rect.height > 0.0 {
196 rect.height.ceil().max(1.0)
197 } else {
198 rect.height
199 },
200 }
201 } else {
202 rect
203 };
204 if draw.arc.is_some_and(|arc| arc.is_degenerate())
208 || draw.stroke.is_some_and(|stroke| !stroke.is_visible())
209 {
210 return;
211 }
212
213 let clip_bounds = match clip_rect_to_bounds(rect, clip, width, height) {
214 Some(bounds) => bounds,
215 None => return,
216 };
217 let Rect {
218 width: rect_width,
219 height: rect_height,
220 ..
221 } = rect;
222
223 let arc = draw.arc.map(|mut arc| {
228 arc.center.x += snap_delta.x;
229 arc.center.y += snap_delta.y;
230 arc
231 });
232 let stroke = draw.stroke;
233 let stroke_outset = stroke.map(|stroke| stroke.half_width()).unwrap_or(0.0);
236 let resolved_shape = draw.shape.map(|shape| {
237 shape.resolve(
238 (rect_width - stroke_outset * 2.0).max(0.0),
239 (rect_height - stroke_outset * 2.0).max(0.0),
240 )
241 });
242
243 for py in clip_bounds.min_y..clip_bounds.max_y {
244 if py < 0 || py >= height as i32 {
245 continue;
246 }
247 for px in clip_bounds.min_x..clip_bounds.max_x {
248 if px < 0 || px >= width as i32 {
249 continue;
250 }
251 let center_x = px as f32 + 0.5;
252 let center_y = py as f32 + 0.5;
253
254 let coverage = if let Some(arc) = arc.as_ref() {
255 shape_sdf::arc_coverage(Point::new(center_x, center_y), arc)
256 } else if let Some(stroke) = stroke {
257 shape_sdf::stroked_rect_coverage(
258 Point::new(center_x, center_y),
259 rect,
260 resolved_shape,
261 stroke.half_width(),
262 stroke.join,
263 )
264 } else {
265 if let Some(ref radii) = resolved_shape {
266 if !point_in_resolved_rounded_rect(center_x, center_y, rect, radii) {
267 continue;
268 }
269 }
270 1.0
271 };
272 if coverage <= 0.0 {
273 continue;
274 }
275
276 let mut sample = sample_brush_rgba(&draw.brush, rect, center_x, center_y);
277 sample[3] *= coverage;
278 let alpha = sample[3];
279 if alpha <= 0.0 {
280 continue;
281 }
282 let idx = ((py as u32 * width + px as u32) * 4) as usize;
283 blend_pixel(
284 &mut frame[idx..idx + 4],
285 sample,
286 draw.blend_mode,
287 diagnostics,
288 );
289 }
290 }
291}
292
293fn draw_image(
294 frame: &mut [u8],
295 width: u32,
296 height: u32,
297 draw: &ImageDraw,
298 diagnostics: &RenderDiagnostics,
299) {
300 let snap_delta = draw
301 .snap_anchor
302 .map(snap_delta_for_anchor)
303 .unwrap_or_default();
304 let rect = draw.rect.translate(snap_delta.x, snap_delta.y);
305 let clip = draw.clip;
306
307 if draw.alpha <= 0.0 || rect.width <= 0.0 || rect.height <= 0.0 {
308 return;
309 }
310
311 let clip_bounds = match clip_rect_to_bounds(rect, clip, width, height) {
312 Some(bounds) => bounds,
313 None => return,
314 };
315
316 let img_width = draw.image.width();
317 let img_height = draw.image.height();
318 if img_width == 0 || img_height == 0 {
319 return;
320 }
321 let src_pixels = draw.image.pixels();
322
323 let (sr_x, sr_y, sr_w, sr_h) = if let Some(sr) = draw.src_rect {
325 (sr.x, sr.y, sr.width, sr.height)
326 } else {
327 (0.0, 0.0, img_width as f32, img_height as f32)
328 };
329
330 for py in clip_bounds.min_y..clip_bounds.max_y {
331 for px in clip_bounds.min_x..clip_bounds.max_x {
332 let sample_x = px as f32 + 0.5;
333 let sample_y = py as f32 + 0.5;
334 let u = ((sample_x - rect.x) / rect.width).clamp(0.0, 1.0);
335 let v = ((sample_y - rect.y) / rect.height).clamp(0.0, 1.0);
336
337 let mut sample = match draw.sampling {
338 cranpose_ui_graphics::ImageSampling::Nearest => {
339 let src_x = ((sr_x + u * sr_w).floor() as i32).clamp(0, img_width as i32 - 1);
340 let src_y = ((sr_y + v * sr_h).floor() as i32).clamp(0, img_height as i32 - 1);
341 sample_image_nearest(src_pixels, img_width, src_x as u32, src_y as u32)
342 }
343 cranpose_ui_graphics::ImageSampling::Linear => sample_image_linear(
344 src_pixels,
345 img_width,
346 img_height,
347 sr_x + u * sr_w - 0.5,
348 sr_y + v * sr_h - 0.5,
349 ),
350 };
351
352 if let Some(filter) = draw.color_filter {
353 sample = apply_color_filter(sample, filter);
354 }
355
356 sample[3] *= draw.alpha.clamp(0.0, 1.0);
357 if sample[3] <= 0.0 {
358 continue;
359 }
360
361 let dst_idx = ((py as u32 * width + px as u32) * 4) as usize;
362 blend_pixel(
363 &mut frame[dst_idx..dst_idx + 4],
364 sample,
365 draw.blend_mode,
366 diagnostics,
367 );
368 }
369 }
370}
371
372fn sample_image_nearest(src_pixels: &[u8], img_width: u32, src_x: u32, src_y: u32) -> [f32; 4] {
373 let src_idx = ((src_y * img_width + src_x) * 4) as usize;
374 [
375 src_pixels[src_idx] as f32 / 255.0,
376 src_pixels[src_idx + 1] as f32 / 255.0,
377 src_pixels[src_idx + 2] as f32 / 255.0,
378 src_pixels[src_idx + 3] as f32 / 255.0,
379 ]
380}
381
382fn sample_image_linear(
383 src_pixels: &[u8],
384 img_width: u32,
385 img_height: u32,
386 x: f32,
387 y: f32,
388) -> [f32; 4] {
389 let x = x.clamp(0.0, img_width.saturating_sub(1) as f32);
390 let y = y.clamp(0.0, img_height.saturating_sub(1) as f32);
391 let x0 = x.floor();
392 let y0 = y.floor();
393 let tx = x - x0;
394 let ty = y - y0;
395 let x0 = (x0 as i32).clamp(0, img_width as i32 - 1) as u32;
396 let y0 = (y0 as i32).clamp(0, img_height as i32 - 1) as u32;
397 let x1 = (x0 + 1).min(img_width - 1);
398 let y1 = (y0 + 1).min(img_height - 1);
399 let top_left = sample_image_nearest(src_pixels, img_width, x0, y0);
400 let top_right = sample_image_nearest(src_pixels, img_width, x1, y0);
401 let bottom_left = sample_image_nearest(src_pixels, img_width, x0, y1);
402 let bottom_right = sample_image_nearest(src_pixels, img_width, x1, y1);
403
404 let mut out = [0.0; 4];
405 for channel in 0..4 {
406 let top = top_left[channel] + (top_right[channel] - top_left[channel]) * tx;
407 let bottom = bottom_left[channel] + (bottom_right[channel] - bottom_left[channel]) * tx;
408 out[channel] = top + (bottom - top) * ty;
409 }
410 out
411}
412
413fn draw_text(
414 frame: &mut [u8],
415 width: u32,
416 height: u32,
417 draw: &TextDraw,
418 diagnostics: &RenderDiagnostics,
419 text_resources: &SoftwareTextResources,
420) {
421 if draw.text.span_styles.is_empty() {
422 draw_text_plain(frame, width, height, draw, diagnostics, text_resources);
423 return;
424 }
425
426 draw_text_with_span_styles(frame, width, height, draw, diagnostics, text_resources);
427}
428
429fn draw_text_with_span_styles(
430 frame: &mut [u8],
431 width: u32,
432 height: u32,
433 draw: &TextDraw,
434 diagnostics: &RenderDiagnostics,
435 text_resources: &SoftwareTextResources,
436) {
437 let boundaries = draw.text.span_boundaries();
438 let mut cursor_x = draw.rect.x;
439 let mut cursor_y = draw.rect.y;
440 let base_line_height = draw
441 .text_style
442 .resolve_line_height(14.0, draw.font_size)
443 .max(1.0);
444 let mut current_line_height = base_line_height;
445
446 for window in boundaries.windows(2) {
447 let start = window[0];
448 let end = window[1];
449 if start == end {
450 continue;
451 }
452
453 let chunk = &draw.text.text[start..end];
454 let mut merged_span = draw.text_style.span_style.clone();
455 for span in &draw.text.span_styles {
456 if span.range.start <= start && span.range.end >= end {
457 merged_span = merged_span.merge(&span.item);
458 }
459 }
460
461 let mut chunk_style = draw.text_style.clone();
462 chunk_style.span_style = merged_span;
463
464 for part in chunk.split_inclusive('\n') {
465 let has_newline = part.ends_with('\n');
466 let content = if has_newline {
467 &part[..part.len().saturating_sub(1)]
468 } else {
469 part
470 };
471
472 if !content.is_empty() {
473 let segment = cranpose_ui::text::AnnotatedString::from(content);
474 let metrics = cranpose_ui::text::measure_text(&segment, &chunk_style);
475 let segment_draw = TextDraw {
476 node_id: draw.node_id,
477 rect: Rect {
478 x: cursor_x,
479 y: cursor_y,
480 width: metrics.width.max(1.0),
481 height: metrics.height.max(1.0),
482 },
483 snap_anchor: draw.snap_anchor,
484 text: Rc::new(segment),
485 color: chunk_style.resolve_text_color(draw.color),
486 text_style: chunk_style.clone(),
487 font_size: chunk_style.resolve_font_size(draw.font_size),
488 scale: draw.scale,
489 layout_options: draw.layout_options,
490 z_index: draw.z_index,
491 clip: draw.clip,
492 };
493 draw_text_plain(
494 frame,
495 width,
496 height,
497 &segment_draw,
498 diagnostics,
499 text_resources,
500 );
501 cursor_x += metrics.width;
502 current_line_height = current_line_height.max(metrics.line_height.max(1.0));
503 }
504
505 if has_newline {
506 cursor_x = draw.rect.x;
507 cursor_y += current_line_height;
508 current_line_height = base_line_height;
509 }
510 }
511 }
512}
513
514fn draw_text_plain(
515 frame: &mut [u8],
516 width: u32,
517 height: u32,
518 draw: &TextDraw,
519 diagnostics: &RenderDiagnostics,
520 text_resources: &SoftwareTextResources,
521) {
522 let text_scale = draw.scale.max(0.0);
523 if text_scale == 0.0 {
524 return;
525 }
526
527 let static_text_motion = draw
528 .text_style
529 .paragraph_style
530 .text_motion
531 .unwrap_or(TextMotion::Static)
532 == TextMotion::Static;
533 let snap_delta = if static_text_motion {
534 draw.snap_anchor
535 .map(snap_delta_for_anchor)
536 .unwrap_or_default()
537 } else {
538 Point::default()
539 };
540 let rect = draw.rect.translate(snap_delta.x, snap_delta.y);
541 let clip = draw.clip;
542
543 let raster_rect = if static_text_motion {
544 Rect {
545 x: rect.x.round(),
546 y: rect.y.round(),
547 width: if rect.width > 0.0 {
548 rect.width.ceil().max(1.0)
549 } else {
550 rect.width
551 },
552 height: if rect.height > 0.0 {
553 rect.height.ceil().max(1.0)
554 } else {
555 rect.height
556 },
557 }
558 } else {
559 rect
560 };
561
562 let Some(font) = text_resources.fonts().resolve(&draw.text_style) else {
563 return;
564 };
565
566 let Some(image) = rasterize_text_to_image(
567 draw.text.text.as_str(),
568 raster_rect,
569 &draw.text_style,
570 draw.color,
571 draw.font_size,
572 text_scale,
573 font,
574 ) else {
575 return;
576 };
577
578 let blit_origin = if static_text_motion {
579 Point::new(raster_rect.x, raster_rect.y)
580 } else {
581 Point::new(rect.x, rect.y)
582 };
583 let blit_rect = Rect {
584 x: blit_origin.x,
585 y: blit_origin.y,
586 width: image.width() as f32,
587 height: image.height() as f32,
588 };
589
590 blit_rasterized_text_image(frame, width, height, blit_rect, clip, &image, diagnostics);
591}
592
593fn blit_rasterized_text_image(
594 frame: &mut [u8],
595 width: u32,
596 height: u32,
597 rect: Rect,
598 clip: Option<Rect>,
599 image: &cranpose_ui_graphics::ImageBitmap,
600 diagnostics: &RenderDiagnostics,
601) {
602 if rect.width <= 0.0 || rect.height <= 0.0 {
603 return;
604 }
605 let clip_bounds = match clip_rect_to_bounds(rect, clip, width, height) {
606 Some(bounds) => bounds,
607 None => return,
608 };
609
610 let img_width = image.width();
611 let img_height = image.height();
612 if img_width == 0 || img_height == 0 {
613 return;
614 }
615 let src_pixels = image.pixels();
616
617 for py in clip_bounds.min_y..clip_bounds.max_y {
618 for px in clip_bounds.min_x..clip_bounds.max_x {
619 let sample_x = px as f32 + 0.5;
620 let sample_y = py as f32 + 0.5;
621 let u = ((sample_x - rect.x) / rect.width).clamp(0.0, 1.0);
622 let v = ((sample_y - rect.y) / rect.height).clamp(0.0, 1.0);
623
624 let src = sample_image_linear(
625 src_pixels,
626 img_width,
627 img_height,
628 u * img_width.saturating_sub(1) as f32,
629 v * img_height.saturating_sub(1) as f32,
630 );
631 if src[3] <= 0.0 {
632 continue;
633 }
634
635 let dst_idx = ((py as u32 * width + px as u32) * 4) as usize;
636 blend_pixel(
637 &mut frame[dst_idx..dst_idx + 4],
638 src,
639 BlendMode::SrcOver,
640 diagnostics,
641 );
642 }
643 }
644}
645
646fn blend_pixel(
647 dst: &mut [u8],
648 src: [f32; 4],
649 blend_mode: BlendMode,
650 diagnostics: &RenderDiagnostics,
651) {
652 let resolved_blend_mode = if is_blend_mode_supported(blend_mode) {
653 blend_mode
654 } else {
655 if diagnostics.claim_warning_once("pixels.unsupported-blend-mode") {
656 log::warn!(
657 "Pixels renderer currently supports BlendMode::SrcOver and BlendMode::DstOut; falling back to SrcOver for unsupported modes"
658 );
659 }
660 BlendMode::SrcOver
661 };
662
663 let src_alpha = src[3].clamp(0.0, 1.0);
664 if src_alpha <= 0.0 {
665 return;
666 }
667 let dst_r = dst[0] as f32 / 255.0;
668 let dst_g = dst[1] as f32 / 255.0;
669 let dst_b = dst[2] as f32 / 255.0;
670 let dst_a = dst[3] as f32 / 255.0;
671
672 let (out_r, out_g, out_b, out_a) = match resolved_blend_mode {
673 BlendMode::DstOut => {
674 let keep = 1.0 - src_alpha;
675 (dst_r * keep, dst_g * keep, dst_b * keep, dst_a * keep)
676 }
677 BlendMode::SrcOver => (
678 src[0].clamp(0.0, 1.0) * src_alpha + dst_r * (1.0 - src_alpha),
679 src[1].clamp(0.0, 1.0) * src_alpha + dst_g * (1.0 - src_alpha),
680 src[2].clamp(0.0, 1.0) * src_alpha + dst_b * (1.0 - src_alpha),
681 src_alpha + dst_a * (1.0 - src_alpha),
682 ),
683 _ => (
684 src[0].clamp(0.0, 1.0) * src_alpha + dst_r * (1.0 - src_alpha),
685 src[1].clamp(0.0, 1.0) * src_alpha + dst_g * (1.0 - src_alpha),
686 src[2].clamp(0.0, 1.0) * src_alpha + dst_b * (1.0 - src_alpha),
687 src_alpha + dst_a * (1.0 - src_alpha),
688 ),
689 };
690
691 dst[0] = (out_r.clamp(0.0, 1.0) * 255.0).round() as u8;
692 dst[1] = (out_g.clamp(0.0, 1.0) * 255.0).round() as u8;
693 dst[2] = (out_b.clamp(0.0, 1.0) * 255.0).round() as u8;
694 dst[3] = (out_a.clamp(0.0, 1.0) * 255.0).round() as u8;
695}
696
697fn apply_color_filter(sample: [f32; 4], filter: ColorFilter) -> [f32; 4] {
698 filter.apply_rgba(sample)
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704 use cranpose_render_common::brush_sampling::normalize_gradient_t;
705 use cranpose_render_common::graph::{
706 CachePolicy, DrawPrimitiveNode, IsolationReasons, LayerNode, PrimitiveEntry, PrimitiveNode,
707 PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode,
708 };
709 use cranpose_render_common::raster_cache::LayerRasterCacheHashes;
710 use cranpose_ui::Brush;
711 use cranpose_ui_graphics::{Color, TileMode};
712
713 fn draw_raster_scene_for_test(frame: &mut [u8], width: u32, height: u32, scene: &RasterScene) {
714 let diagnostics = RenderDiagnostics::new();
715 let text_resources = SoftwareTextResources::default();
716 draw_raster_scene(frame, width, height, scene, &diagnostics, &text_resources);
717 }
718
719 #[test]
720 fn fallback_text_metrics_cover_empty_and_multiline_text() {
721 let empty = fallback_text_metrics("", 10.0);
722 assert_eq!(empty.line_count, 1);
723 assert_eq!(empty.width, 0.0);
724 assert_eq!(empty.height, fallback_line_height(10.0));
725
726 let multiline = fallback_text_metrics("ab\ncde", 10.0);
727 assert_eq!(multiline.line_count, 2);
728 assert_eq!(multiline.width, 3.0 * fallback_char_width(10.0));
729 assert_eq!(multiline.height, 2.0 * fallback_line_height(10.0));
730 }
731
732 #[test]
733 fn fallback_cursor_position_handles_non_boundary_byte_offsets() {
734 let text = "éx";
735 let width = fallback_char_width(12.0);
736 assert_eq!(fallback_cursor_x_for_byte_offset(text, 0, 12.0), 0.0);
737 assert_eq!(fallback_cursor_x_for_byte_offset(text, 1, 12.0), width);
738 assert_eq!(
739 fallback_cursor_x_for_byte_offset(text, text.len(), 12.0),
740 width * 2.0
741 );
742 }
743
744 #[test]
745 fn shape_snap_does_not_move_its_fixed_ancestor_clip() {
746 let draw = crate::scene::DrawShape {
747 rect: Rect {
748 x: 0.0,
749 y: 0.0,
750 width: 8.0,
751 height: 8.0,
752 },
753 snap_anchor: Some(Point::new(0.4, 0.4)),
754 snap_to_pixel_grid: false,
755 brush: Brush::solid(Color::WHITE),
756 shape: None,
757 stroke: None,
758 arc: None,
759 z_index: 0,
760 clip: Some(Rect {
761 x: 2.0,
762 y: 2.0,
763 width: 2.0,
764 height: 2.0,
765 }),
766 blend_mode: BlendMode::SrcOver,
767 };
768 let mut frame = vec![0; 8 * 8 * 4];
769 draw_shape(&mut frame, 8, 8, &draw, &RenderDiagnostics::new());
770
771 let alpha = |x: usize, y: usize| frame[(y * 8 + x) * 4 + 3];
772 assert_eq!(alpha(1, 2), 0, "content snapping moved the clip left");
773 assert_eq!(alpha(2, 2), 255, "the fixed clip must retain its coverage");
774 }
775
776 fn count_non_background_pixels(frame: &[u8], width: u32, height: u32) -> usize {
777 count_non_background_pixels_in_band(frame, width, 0, height)
778 }
779
780 fn render_single_text_frame(
781 style: cranpose_ui::TextStyle,
782 color: Color,
783 x: f32,
784 ) -> (u32, u32, Vec<u8>) {
785 let mut raster_scene = RasterScene::new();
786 raster_scene.push_text(
787 11,
788 Rect {
789 x,
790 y: 16.0,
791 width: 320.0,
792 height: 90.0,
793 },
794 Rc::new(cranpose_ui::text::AnnotatedString::from("MMMMMMMM")),
795 color,
796 style,
797 64.0,
798 1.0,
799 cranpose_ui::TextLayoutOptions::default(),
800 None,
801 );
802
803 let width = 360;
804 let height = 140;
805 let mut frame = vec![0u8; (width * height * 4) as usize];
806 draw_raster_scene_for_test(&mut frame, width, height, &raster_scene);
807 (width, height, frame)
808 }
809
810 fn average_ink_rgb(
811 frame: &[u8],
812 width: u32,
813 x_min: u32,
814 x_max: u32,
815 y_min: u32,
816 y_max: u32,
817 ) -> Option<[f32; 3]> {
818 let mut sum_r = 0.0f32;
819 let mut sum_g = 0.0f32;
820 let mut sum_b = 0.0f32;
821 let mut count = 0usize;
822
823 for y in y_min..y_max {
824 for x in x_min..x_max {
825 let idx = ((y * width + x) * 4) as usize;
826 let px = &frame[idx..idx + 4];
827 if px == [18, 18, 24, 255] {
828 continue;
829 }
830 sum_r += px[0] as f32 / 255.0;
831 sum_g += px[1] as f32 / 255.0;
832 sum_b += px[2] as f32 / 255.0;
833 count += 1;
834 }
835 }
836
837 if count == 0 {
838 return None;
839 }
840 Some([
841 sum_r / count as f32,
842 sum_g / count as f32,
843 sum_b / count as f32,
844 ])
845 }
846
847 fn count_non_background_pixels_in_band(
848 frame: &[u8],
849 width: u32,
850 y_min_inclusive: u32,
851 y_max_exclusive: u32,
852 ) -> usize {
853 let mut count = 0usize;
854 for y in y_min_inclusive..y_max_exclusive {
855 for x in 0..width {
856 let idx = ((y * width + x) * 4) as usize;
857 let px = &frame[idx..idx + 4];
858 if px != [18, 18, 24, 255] {
859 count += 1;
860 }
861 }
862 }
863 count
864 }
865
866 fn ink_y_range(frame: &[u8], width: u32, height: u32) -> Option<(u32, u32)> {
868 let mut top = None;
869 let mut bottom = 0u32;
870 for y in 0..height {
871 for x in 0..width {
872 let idx = ((y * width + x) * 4) as usize;
873 if frame[idx..idx + 4] != [18, 18, 24, 255] {
874 top.get_or_insert(y);
875 bottom = y + 1;
876 break;
877 }
878 }
879 }
880 top.map(|t| (t, bottom))
881 }
882
883 #[test]
884 fn blend_mode_support_matrix_is_explicit() {
885 assert!(is_blend_mode_supported(BlendMode::SrcOver));
886 assert!(is_blend_mode_supported(BlendMode::DstOut));
887 assert!(!is_blend_mode_supported(BlendMode::Clear));
888 assert!(!is_blend_mode_supported(BlendMode::Multiply));
889 }
890
891 #[test]
892 fn unsupported_blend_mode_falls_back_without_abort() {
893 let diagnostics = RenderDiagnostics::new();
894 let src = [1.0, 0.0, 0.0, 0.5];
895 let mut unsupported = [0, 0, 255, 255];
896 let mut src_over = unsupported;
897
898 blend_pixel(&mut unsupported, src, BlendMode::Multiply, &diagnostics);
899 blend_pixel(&mut src_over, src, BlendMode::SrcOver, &diagnostics);
900
901 assert_eq!(unsupported, src_over);
902 }
903
904 #[test]
905 fn mirror_tile_mode_reflects_second_interval() {
906 assert_eq!(normalize_gradient_t(1.25, TileMode::Mirror), Some(0.75));
907 assert_eq!(normalize_gradient_t(1.75, TileMode::Mirror), Some(0.25));
908 }
909
910 #[test]
911 fn multiline_text_renders_second_line_pixels() {
912 let mut raster_scene = RasterScene::new();
913 raster_scene.push_text(
914 1,
915 Rect {
916 x: 8.0,
917 y: 8.0,
918 width: 180.0,
919 height: 80.0,
920 },
921 Rc::new(cranpose_ui::text::AnnotatedString::from(
922 "Dynamic\nModifiers",
923 )),
924 Color::WHITE,
925 cranpose_ui::TextStyle::default(),
926 14.0,
927 1.0,
928 cranpose_ui::TextLayoutOptions::default(),
929 None,
930 );
931
932 let width = 220;
933 let height = 100;
934 let mut frame = vec![0u8; (width * height * 4) as usize];
935 draw_raster_scene_for_test(&mut frame, width, height, &raster_scene);
936
937 let (ink_top, ink_bottom) =
939 ink_y_range(&frame, width, height).expect("expected ink pixels in rendered text");
940 let ink_height = ink_bottom - ink_top;
941 assert!(
942 ink_height >= 20,
943 "expected two lines of ink, ink spans only {ink_height}px (y={ink_top}..{ink_bottom})"
944 );
945 let mid_y = ink_top + ink_height / 2;
946 let first_line_ink = count_non_background_pixels_in_band(&frame, width, ink_top, mid_y);
947 let second_line_ink = count_non_background_pixels_in_band(&frame, width, mid_y, ink_bottom);
948 assert!(
949 first_line_ink > 20,
950 "expected first line to render, got {first_line_ink}"
951 );
952 assert!(
953 second_line_ink > 20,
954 "expected second line ink, got {second_line_ink}"
955 );
956 }
957
958 #[test]
959 fn draw_scene_renders_graph_backed_scene_without_flat_primitives() {
960 let mut scene = Scene::new();
961 scene.graph = Some(RenderGraph::new(LayerNode {
962 node_id: None,
963 local_bounds: Rect {
964 x: 0.0,
965 y: 0.0,
966 width: 16.0,
967 height: 16.0,
968 },
969 transform_to_parent: ProjectiveTransform::identity(),
970 motion_context_animated: false,
971 translated_content_context: false,
972 translated_content_offset: cranpose_ui_graphics::Point::default(),
973 content_offset: cranpose_ui_graphics::Point::default(),
974 scene_children_origin: cranpose_ui_graphics::Point::default(),
975 scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
976 graphics_layer: cranpose_ui_graphics::GraphicsLayer::default(),
977 clip_to_bounds: false,
978 shadow_clip: None,
979 hit_test: None,
980 has_hit_targets: false,
981 isolation: IsolationReasons::default(),
982 cache_policy: CachePolicy::None,
983 cache_hashes: LayerRasterCacheHashes::default(),
984 cache_hashes_valid: false,
985 children: vec![RenderNode::Primitive(PrimitiveEntry {
986 phase: PrimitivePhase::BeforeChildren,
987 node: PrimitiveNode::Draw(DrawPrimitiveNode {
988 primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
989 rect: Rect {
990 x: 2.0,
991 y: 3.0,
992 width: 6.0,
993 height: 5.0,
994 },
995 brush: Brush::solid(Color::WHITE),
996 stroke: None,
997 },
998 clip: None,
999 }),
1000 })],
1001 }));
1002
1003 let width = 20;
1004 let height = 20;
1005 let mut frame = vec![0u8; (width * height * 4) as usize];
1006 draw_scene(&mut frame, width, height, &scene);
1007
1008 assert!(
1009 count_non_background_pixels(&frame, width, height) > 0,
1010 "graph-backed scenes should render even when flat primitive arrays are empty"
1011 );
1012 }
1013
1014 #[test]
1015 fn text_clip_bounds_prevent_drawing_outside_scroll_window() {
1016 let mut raster_scene = RasterScene::new();
1017 raster_scene.push_text(
1018 2,
1019 Rect {
1020 x: 8.0,
1021 y: 40.0,
1022 width: 180.0,
1023 height: 24.0,
1024 },
1025 Rc::new(cranpose_ui::text::AnnotatedString::from("Clipped Text")),
1026 Color::WHITE,
1027 cranpose_ui::TextStyle::default(),
1028 14.0,
1029 1.0,
1030 cranpose_ui::TextLayoutOptions::default(),
1031 Some(Rect {
1032 x: 0.0,
1033 y: 0.0,
1034 width: 220.0,
1035 height: 20.0,
1036 }),
1037 );
1038
1039 let width = 220;
1040 let height = 100;
1041 let mut frame = vec![0u8; (width * height * 4) as usize];
1042 draw_raster_scene_for_test(&mut frame, width, height, &raster_scene);
1043
1044 let total_ink = count_non_background_pixels_in_band(&frame, width, 0, height);
1045 assert_eq!(
1046 total_ink, 0,
1047 "text should be fully clipped but rendered {total_ink} ink pixels"
1048 );
1049 }
1050
1051 #[test]
1052 fn gradient_brush_contract_requires_visible_color_transition() {
1053 let style = cranpose_ui::TextStyle {
1054 span_style: cranpose_ui::SpanStyle {
1055 brush: Some(Brush::linear_gradient_range(
1056 vec![Color(1.0, 0.0, 0.0, 1.0), Color(0.0, 0.0, 1.0, 1.0)],
1057 cranpose_ui_graphics::Point::new(0.0, 0.0),
1058 cranpose_ui_graphics::Point::new(320.0, 0.0),
1059 )),
1060 ..Default::default()
1061 },
1062 ..Default::default()
1063 };
1064
1065 let (width, _height, frame) = render_single_text_frame(style, Color::WHITE, 12.0);
1066 let left = average_ink_rgb(&frame, width, 20, 150, 20, 120).expect("left ink");
1067 let right = average_ink_rgb(&frame, width, 200, 340, 20, 120).expect("right ink");
1068
1069 assert!(
1070 left[0] > left[2] * 1.15,
1071 "left side should be red-dominant for horizontal gradient, got {left:?}"
1072 );
1073 assert!(
1074 right[2] > right[0] * 1.15,
1075 "right side should be blue-dominant for horizontal gradient, got {right:?}"
1076 );
1077 }
1078
1079 #[test]
1080 fn draw_style_stroke_contract_changes_raster_output() {
1081 let fill_style = cranpose_ui::TextStyle::default();
1082 let stroke_style = cranpose_ui::TextStyle {
1083 span_style: cranpose_ui::SpanStyle {
1084 draw_style: Some(cranpose_ui::text::TextDrawStyle::Stroke { width: 6.0 }),
1085 ..Default::default()
1086 },
1087 ..Default::default()
1088 };
1089
1090 let (width, height, fill_frame) = render_single_text_frame(fill_style, Color::WHITE, 12.0);
1091 let (_, _, stroke_frame) = render_single_text_frame(stroke_style, Color::WHITE, 12.0);
1092 let fill_ink = count_non_background_pixels(&fill_frame, width, height);
1093 let stroke_ink = count_non_background_pixels(&stroke_frame, width, height);
1094
1095 assert_ne!(
1096 fill_frame, stroke_frame,
1097 "Fill and Stroke text must not rasterize identically"
1098 );
1099 assert!(
1100 fill_ink.abs_diff(stroke_ink) > 250,
1101 "Fill/Stroke ink coverage should differ; fill={fill_ink}, stroke={stroke_ink}"
1102 );
1103 }
1104
1105 #[test]
1106 fn shadow_blur_radius_contract_changes_raster_output() {
1107 let base_shadow = cranpose_ui::text::Shadow {
1108 color: Color(0.0, 0.0, 0.0, 0.85),
1109 offset: cranpose_ui_graphics::Point::new(6.0, 4.0),
1110 blur_radius: 0.0,
1111 };
1112 let zero_blur_style = cranpose_ui::TextStyle {
1113 span_style: cranpose_ui::SpanStyle {
1114 shadow: Some(base_shadow),
1115 ..Default::default()
1116 },
1117 ..Default::default()
1118 };
1119 let blurred_style = cranpose_ui::TextStyle {
1120 span_style: cranpose_ui::SpanStyle {
1121 shadow: Some(cranpose_ui::text::Shadow {
1122 blur_radius: 10.0,
1123 ..base_shadow
1124 }),
1125 ..Default::default()
1126 },
1127 ..Default::default()
1128 };
1129
1130 let (_, _, zero_frame) = render_single_text_frame(zero_blur_style, Color::WHITE, 12.0);
1131 let (_, _, blur_frame) = render_single_text_frame(blurred_style, Color::WHITE, 12.0);
1132
1133 assert_ne!(
1134 zero_frame, blur_frame,
1135 "Changing shadow blur radius must change rendered output"
1136 );
1137 }
1138
1139 #[test]
1140 fn text_motion_contract_changes_raster_output() {
1141 let static_style = cranpose_ui::TextStyle {
1142 paragraph_style: cranpose_ui::ParagraphStyle {
1143 text_motion: Some(cranpose_ui::text::TextMotion::Static),
1144 ..Default::default()
1145 },
1146 ..Default::default()
1147 };
1148 let animated_style = cranpose_ui::TextStyle {
1149 paragraph_style: cranpose_ui::ParagraphStyle {
1150 text_motion: Some(cranpose_ui::text::TextMotion::Animated),
1151 ..Default::default()
1152 },
1153 ..Default::default()
1154 };
1155
1156 let (_, _, static_frame) = render_single_text_frame(static_style, Color::WHITE, 12.35);
1157 let (_, _, animated_frame) = render_single_text_frame(animated_style, Color::WHITE, 12.35);
1158
1159 assert_ne!(
1160 static_frame, animated_frame,
1161 "TextMotion::Static and TextMotion::Animated should not rasterize identically"
1162 );
1163 }
1164
1165 const CANVAS: u32 = 64;
1173
1174 fn blank_frame() -> Vec<u8> {
1175 vec![0u8; (CANVAS * CANVAS * 4) as usize]
1176 }
1177
1178 fn is_background(frame: &[u8], x: u32, y: u32) -> bool {
1179 let idx = ((y * CANVAS + x) * 4) as usize;
1180 frame[idx..idx + 4] == [18, 18, 24, 255]
1181 }
1182
1183 fn is_inked(frame: &[u8], x: u32, y: u32) -> bool {
1184 !is_background(frame, x, y)
1185 }
1186
1187 fn render_shape(shape: crate::scene::DrawShape) -> Vec<u8> {
1188 let mut scene = RasterScene::new();
1189 scene.shapes.push(shape);
1190 let mut frame = blank_frame();
1191 draw_raster_scene_for_test(&mut frame, CANVAS, CANVAS, &scene);
1192 frame
1193 }
1194
1195 fn shape_template(rect: Rect) -> crate::scene::DrawShape {
1196 crate::scene::DrawShape {
1197 rect,
1198 snap_anchor: None,
1199 snap_to_pixel_grid: false,
1200 brush: Brush::solid(Color::WHITE),
1201 shape: None,
1202 stroke: None,
1203 arc: None,
1204 z_index: 0,
1205 clip: None,
1206 blend_mode: BlendMode::SrcOver,
1207 }
1208 }
1209
1210 #[test]
1211 fn stroked_rect_rasterizes_hollow() {
1212 let mut shape = shape_template(Rect {
1214 x: 14.0,
1215 y: 14.0,
1216 width: 36.0,
1217 height: 36.0,
1218 });
1219 shape.stroke = Some(cranpose_ui_graphics::Stroke::new(4.0));
1220 let frame = render_shape(shape);
1221
1222 assert!(is_inked(&frame, 32, 16), "top edge must be stroked");
1223 assert!(is_inked(&frame, 16, 32), "left edge must be stroked");
1224 assert!(is_inked(&frame, 48, 32), "right edge must be stroked");
1225 assert!(is_inked(&frame, 32, 48), "bottom edge must be stroked");
1226 assert!(
1227 is_background(&frame, 32, 32),
1228 "the interior of a stroked rect must stay empty — a silent fallback \
1229 to a filled rect would fill it"
1230 );
1231 assert!(is_background(&frame, 32, 8), "outside must stay empty");
1232 }
1233
1234 #[test]
1235 fn stroked_rect_differs_from_the_filled_rect_of_the_same_bounds() {
1236 let rect = Rect {
1237 x: 14.0,
1238 y: 14.0,
1239 width: 36.0,
1240 height: 36.0,
1241 };
1242 let filled = render_shape(shape_template(rect));
1243 let mut stroked_shape = shape_template(rect);
1244 stroked_shape.stroke = Some(cranpose_ui_graphics::Stroke::new(4.0));
1245 let stroked = render_shape(stroked_shape);
1246 assert_ne!(filled, stroked);
1247 }
1248
1249 #[test]
1250 fn arc_band_rasterizes_between_the_two_radii() {
1251 let mut shape = shape_template(Rect {
1253 x: 16.0,
1254 y: 16.0,
1255 width: 32.0,
1256 height: 32.0,
1257 });
1258 shape.arc = Some(cranpose_ui_graphics::ArcGeometry::new(
1259 Point::new(32.0, 32.0),
1260 10.0,
1261 16.0,
1262 0.0,
1263 cranpose_ui_graphics::TAU,
1264 cranpose_ui_graphics::StrokeCap::Butt,
1265 ));
1266 let frame = render_shape(shape);
1267
1268 assert!(is_background(&frame, 32, 32), "the hole must stay empty");
1269 assert!(is_inked(&frame, 45, 32), "+X band");
1271 assert!(is_inked(&frame, 19, 32), "-X band");
1272 assert!(is_inked(&frame, 32, 45), "+Y band");
1273 assert!(
1274 is_inked(&frame, 32, 19),
1275 "-Y band — a seam here would mean the full-turn wrap is mishandled"
1276 );
1277 }
1278
1279 #[test]
1280 fn annular_sector_has_flat_radial_edges_and_respects_the_sweep() {
1281 let mut shape = shape_template(Rect {
1284 x: 32.0,
1285 y: 32.0,
1286 width: 16.0,
1287 height: 16.0,
1288 });
1289 shape.arc = Some(cranpose_ui_graphics::ArcGeometry::new(
1290 Point::new(32.0, 32.0),
1291 8.0,
1292 16.0,
1293 0.0,
1294 std::f32::consts::FRAC_PI_2,
1295 cranpose_ui_graphics::StrokeCap::Butt,
1296 ));
1297 let frame = render_shape(shape);
1298
1299 assert!(is_inked(&frame, 44, 33), "inside the sector near 0 degrees");
1301 assert!(
1302 is_inked(&frame, 33, 44),
1303 "inside the sector near 90 degrees"
1304 );
1305 assert!(
1308 is_background(&frame, 44, 30),
1309 "past the flat radial start edge must be empty"
1310 );
1311 assert!(
1312 is_background(&frame, 30, 44),
1313 "past the flat radial end edge must be empty"
1314 );
1315 assert!(is_background(&frame, 35, 35), "inner hole");
1317 assert!(is_background(&frame, 52, 33), "beyond the outer radius");
1318 }
1319
1320 #[test]
1321 fn arc_and_stroke_rasterization_never_writes_nan_or_panics() {
1322 for arc in [
1325 cranpose_ui_graphics::ArcGeometry::new(
1326 Point::new(32.0, 32.0),
1327 10.0,
1328 10.0,
1329 0.0,
1330 1.0,
1331 cranpose_ui_graphics::StrokeCap::Butt,
1332 ),
1333 cranpose_ui_graphics::ArcGeometry::new(
1334 Point::new(32.0, 32.0),
1335 0.0,
1336 0.0,
1337 0.0,
1338 0.0,
1339 cranpose_ui_graphics::StrokeCap::Round,
1340 ),
1341 ] {
1342 let mut shape = shape_template(Rect {
1343 x: 0.0,
1344 y: 0.0,
1345 width: 64.0,
1346 height: 64.0,
1347 });
1348 shape.arc = Some(arc);
1349 let frame = render_shape(shape);
1350 assert!(
1351 (0..CANVAS).all(|y| (0..CANVAS).all(|x| is_background(&frame, x, y))),
1352 "a degenerate arc must draw nothing"
1353 );
1354 }
1355
1356 let mut zero_width = shape_template(Rect {
1357 x: 8.0,
1358 y: 8.0,
1359 width: 32.0,
1360 height: 32.0,
1361 });
1362 zero_width.stroke = Some(cranpose_ui_graphics::Stroke::new(0.0));
1363 let frame = render_shape(zero_width);
1364 assert!(
1365 (0..CANVAS).all(|y| (0..CANVAS).all(|x| is_background(&frame, x, y))),
1366 "a zero-width stroke must draw nothing"
1367 );
1368 }
1369}