cranpose-render-wgpu 0.9.21

GPU renderer for Cranpose scenes through wgpu
Documentation
#[path = "../../common/tests/support/annotated_text.rs"]
mod annotated_fixture;

use cranpose_app_shell::AppShell;
use cranpose_core::location_key;
use cranpose_ui::{
    Box, BoxSpec, Color, Modifier, Text, TextStyle,
    text::{
        AnnotatedString, LineHeightAlignment, LineHeightStyle, LineHeightTrim, RangeStyle,
        SpanStyle, TextMotion, TextUnit,
    },
};
use cranpose_ui_graphics::Rect;

use crate::support;

#[test]
fn translating_cached_gradient_text_matches_a_fresh_render_at_its_new_position() {
    for annotated in [false, true] {
        let graph = |x| {
            let mut text = AnnotatedString::from("HH");
            if annotated {
                text.span_styles.push(RangeStyle {
                    range: 1..2,
                    item: SpanStyle {
                        font_size: TextUnit::Sp(30.0),
                        ..Default::default()
                    },
                });
            }
            let style = TextStyle::from_span_style(SpanStyle {
                font_size: TextUnit::Sp(20.0),
                brush: Some(cranpose_ui_graphics::Brush::linear_gradient(
                    (0..17)
                        .map(|index| {
                            let t = index as f32 / 16.0;
                            Color(0.2 + 0.5 * t, 0.3 + 0.3 * t, 0.4 + 0.1 * t, 1.0)
                        })
                        .collect(),
                )),
                ..Default::default()
            });
            annotated_fixture::text_graph(
                text,
                style,
                Rect {
                    x,
                    y: x,
                    width: 64.0,
                    height: 60.0,
                },
                Rect {
                    x: 0.0,
                    y: 0.0,
                    width: 80.0,
                    height: 80.0,
                },
            )
        };
        let warm = {
            let mut renderer = match support::headless_renderer() {
                Ok(renderer) => renderer,
                Err(error) => {
                    eprintln!("headless WGPU unavailable: {error}");
                    return;
                }
            };
            support::capture_graph(&mut renderer, graph(4.0), 80, 80);
            support::capture_graph(&mut renderer, graph(5.0), 80, 80)
        };
        let mut fresh = support::headless_renderer().expect("headless renderer remains available");
        let cold = support::capture_graph(&mut fresh, graph(5.0), 80, 80);
        assert!(
            cold.pixels
                .as_chunks::<4>()
                .0
                .iter()
                .any(|pixel| pixel != &cold.pixels[..4]),
            "the reference frame contains visible gradient glyphs"
        );
        assert_eq!(
            warm.pixels, cold.pixels,
            "translated gradient text must not depend on image cache history (annotated={annotated})"
        );
    }
}

#[test]
fn clipping_variable_height_lines_preserves_the_visible_pixels() {
    assert_clipped_paragraph_pixels(17, true);
}

#[test]
fn clipping_shadowed_text_effects_preserves_the_visible_pixels() {
    assert_clipped_paragraph_pixels(2, false);
}

fn assert_clipped_paragraph_pixels(color_stops: usize, shadow_on_span: bool) {
    let mut renderer = match support::headless_renderer() {
        Ok(renderer) => renderer,
        Err(error) => {
            eprintln!("headless WGPU unavailable: {error}");
            return;
        }
    };
    let mut text = AnnotatedString::from("H\nH\nH\nH\nH\nH\nH\nH\nH\nH");
    text.span_styles.push(RangeStyle {
        range: 0..1,
        item: SpanStyle {
            font_size: TextUnit::Sp(40.0),
            ..Default::default()
        },
    });
    for height_and_trim in [
        None,
        Some((14.0, LineHeightTrim::None)),
        Some((32.0, LineHeightTrim::Both)),
    ] {
        for (varying, shadowed_stroke) in [(false, false), (true, false), (false, true)] {
            let mut style = TextStyle::from_span_style(SpanStyle {
                font_size: TextUnit::Sp(10.0),
                brush: Some(cranpose_ui_graphics::Brush::linear_gradient(
                    (0..color_stops)
                        .map(|index| {
                            let t = index as f32 / (color_stops - 1) as f32;
                            if varying {
                                Color(1.0 - t, 0.0, t, 1.0)
                            } else {
                                Color::WHITE
                            }
                        })
                        .collect(),
                )),
                ..Default::default()
            });
            if shadowed_stroke {
                style.span_style.draw_style =
                    Some(cranpose_ui::text::TextDrawStyle::Stroke { width: 2.0 });
                style.span_style.shadow = (!shadow_on_span).then_some(cranpose_ui::text::Shadow {
                    color: Color::WHITE,
                    offset: cranpose_ui_graphics::Point::new(-3.25, 5.75),
                    blur_radius: 4.0,
                });
                style.span_style.font_weight = Some(cranpose_ui::text::FontWeight::BLACK);
                style.span_style.font_style = Some(cranpose_ui::text::FontStyle::Italic);
            }
            if let Some((height, trim)) = height_and_trim {
                style.paragraph_style.line_height = TextUnit::Sp(height);
                style.paragraph_style.line_height_style = Some(LineHeightStyle {
                    alignment: LineHeightAlignment::Center,
                    trim,
                    ..Default::default()
                });
            }
            let graph = |y, height| {
                let mut styled_text = text.clone();
                if shadowed_stroke && shadow_on_span {
                    styled_text.span_styles.push(RangeStyle {
                        range: 0..styled_text.text.len(),
                        item: SpanStyle {
                            shadow: Some(cranpose_ui::text::Shadow {
                                color: Color::WHITE,
                                offset: cranpose_ui_graphics::Point::new(-3.25, 5.75),
                                blur_radius: 4.0,
                            }),
                            ..Default::default()
                        },
                    });
                }
                annotated_fixture::text_graph(
                    styled_text,
                    style.clone(),
                    Rect {
                        x: 4.0,
                        y,
                        width: 64.0,
                        height: 360.0,
                    },
                    Rect {
                        x: 0.0,
                        y: 0.0,
                        width: 80.0,
                        height,
                    },
                )
            };
            let whole = support::capture_graph(&mut renderer, graph(0.0, 400.0), 80, 400);
            drop(renderer);
            renderer = support::headless_renderer().expect("headless renderer remains available");
            for offset in [61.0, 82.0] {
                let clipped = support::capture_graph(&mut renderer, graph(-offset, 30.0), 80, 30);
                let reference = support::region_pixels(
                    &whole,
                    Rect {
                        x: 0.0,
                        y: offset,
                        width: 80.0,
                        height: 30.0,
                    },
                );
                assert!(
                    reference.as_chunks::<4>().0.iter().any(|p| p[0] > 128),
                    "the clipped region contains visible glyphs"
                );
                if !varying {
                    assert_eq!(
                        clipped.pixels, reference,
                        "clipping preserves exact glyph coverage: {height_and_trim:?}, shadowed_stroke={shadowed_stroke}, offset={offset}"
                    );
                } else {
                    for (actual, expected) in clipped
                        .pixels
                        .as_chunks::<4>()
                        .0
                        .iter()
                        .zip(reference.as_chunks::<4>().0.iter())
                    {
                        assert!(
                            actual
                                .iter()
                                .zip(expected)
                                .all(|(a, b)| a.abs_diff(*b) <= 3),
                            "clipping changed ink beyond gradient dither tolerance: {height_and_trim:?}, offset={offset}, {actual:?} vs {expected:?}"
                        );
                    }
                }
            }
        }
    }
}

#[test]
fn static_and_animated_rich_text_share_visible_baselines_with_leading() {
    let (_lock, renderer) = match support::headless_renderer_parts() {
        Ok(parts) => parts,
        Err(error) => {
            eprintln!("headless WGPU unavailable: {error}");
            return;
        }
    };
    let text = annotated_fixture::mixed_size_pairs();
    let mut shell = AppShell::new(
        renderer,
        location_key(file!(), line!(), column!()),
        move || {
            let text = text.clone();
            Box(
                Modifier::empty()
                    .size_points(160.0, 160.0)
                    .background(Color::BLACK),
                BoxSpec::default(),
                move || {
                    for (index, motion) in [TextMotion::Static, TextMotion::Animated]
                        .into_iter()
                        .enumerate()
                    {
                        for (line_height, y) in [(40.0, 0.0), (14.0, 100.0)] {
                            let mut style = TextStyle::from_span_style(SpanStyle {
                                font_size: TextUnit::Sp(10.0),
                                ..Default::default()
                            });
                            style.paragraph_style.line_height = TextUnit::Sp(line_height);
                            style.paragraph_style.line_height_style = Some(LineHeightStyle {
                                alignment: LineHeightAlignment::Center,
                                trim: LineHeightTrim::None,
                                ..Default::default()
                            });
                            style.paragraph_style.text_motion = Some(motion);
                            Text(
                                text.clone(),
                                Modifier::empty().absolute_offset(index as f32 * 80.0, y),
                                style,
                            );
                        }
                    }
                },
            );
        },
    );
    shell.set_viewport(160.0, 160.0);
    shell.set_buffer_size(160, 160);
    shell.update();
    let frame = shell
        .renderer()
        .capture_frame(160, 160)
        .expect("captured rich text");
    for x in [0, 80] {
        for (top, bottom) in [(0, 40), (40, 80), (114, 140)] {
            let ink_bottom = |channel: usize| {
                (top..bottom)
                    .rev()
                    .find(|y| {
                        (x..x + 70).any(|x| {
                            let index = (y * 160 + x) * 4;
                            frame.pixels[index + channel] > 64
                                && frame.pixels[index + channel]
                                    > frame.pixels[index + 1 - channel].saturating_mul(2)
                        })
                    })
                    .expect("each colored glyph is visible")
            };
            let small = ink_bottom(0);
            let large = ink_bottom(1);
            assert!(
                small.abs_diff(large) <= 1,
                "column {x}, line {top}: small baseline={small}, large baseline={large}"
            );
        }
    }
}