Skip to main content

cranpose_render_pixels/
draw.rs

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    // Clips are already resolved in scene space and may belong to a fixed
181    // ancestor. Only the draw geometry borrows this item's raster snap.
182    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    // Source region: either a sub-rect or the full image
275    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    /// Returns `(top_y, bottom_y)` (exclusive) of all non-background ink rows.
816    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        // Find the y-range of all ink pixels (font-agnostic approach).
887        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}