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