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::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    // Unique z per item (the scene's `next_z` counter), so unstable sorting is
139    // order-identical and avoids the stable sort's per-frame scratch allocation.
140    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    // Clips are already resolved in scene space and may belong to a fixed
184    // ancestor. Only the draw geometry borrows this item's raster snap.
185    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    // Empty geometry draws nothing. The shared lowering already drops these,
205    // but a `DrawShape` can also be built directly (shadow casters, caches), so
206    // guard here too rather than emitting a hairline for a zero-area band.
207    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    // Stroked outlines and arcs are rasterized from the very same signed
224    // distance functions the GPU shader evaluates (see
225    // `cranpose_render_common::shape_sdf`), so the software backend draws the
226    // real shape instead of degrading a stroke to a filled box.
227    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    // For a stroked shape `rect` is already inflated by half the stroke width,
234    // so corner radii must resolve against the geometry that was asked for.
235    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    // Source region: either a sub-rect or the full image
324    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    /// Returns `(top_y, bottom_y)` (exclusive) of all non-background ink rows.
867    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        // Find the y-range of all ink pixels (font-agnostic approach).
938        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    // ── Stroke / arc rasterization ──────────────────────────────────────────
1166    //
1167    // The software backend must draw the real stroked/arc shape. Falling back
1168    // to a filled rect would look plausible in a screenshot diff but is simply
1169    // the wrong picture, so these assert the defining properties: a stroke is
1170    // hollow, an arc is a band, and an annular sector has flat radial edges.
1171
1172    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        // Geometry (16,16)-(48,48) stroked at width 4 => bounds (14,14)-(50,50).
1213        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        // Full ring, inner 10, outer 16, centered at (32, 32).
1252        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        // Centerline radius 13 in each cardinal direction.
1270        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        // 0 -> 90 degrees (i.e. +X sweeping down to +Y in screen space),
1282        // inner 8, outer 16, centered at (32, 32).
1283        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        // Inside the sweep, between the radii.
1300        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        // Outside the sweep at the same radius: the radial edge is flat, so
1306        // one pixel the other side of the start angle is empty.
1307        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        // Inside the hole and outside the outer radius.
1316        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        // Degenerate geometry can reach the rasterizer through a translated or
1323        // cached scene; it must simply draw nothing.
1324        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}