lib.rs raw

//! Sample scenes exercising the display list, shared between the runnable
//! examples and the golden-image tests so both always render the same
//! content.
//!
//! Text uses only an embedded DejaVu Sans (no system fonts), keeping rendered
//! output identical across machines.

use guiduck_core::text::TextContext;
use guiduck_scene::geom::{Affine, BezPath, Point, Rect, Shape as _, Size, Stroke};
use guiduck_scene::paint::color::palette::css;
use guiduck_scene::paint::{
    BlendMode, Blob, Compose, Fill, Gradient, ImageAlphaType, ImageBrush, ImageData, ImageFormat,
    Mix,
};
use guiduck_scene::{Fragment, FragmentId, FragmentStore};
use parley::{Alignment, AlignmentOptions, FontFamily, Layout, StyleProperty};

pub mod counter;
pub mod stress;
pub mod widgets;

/// The embedded font family's name as registered with fontique.
pub const SAMPLE_FONT_FAMILY: &str = "DejaVu Sans";

static SAMPLE_FONT: &[u8] = include_bytes!("../fonts/DejaVuSans.ttf");

/// The embedded sample font as a shareable blob. One process-wide blob:
/// resource identity matters (blobs compare by id), so every text context
/// built from this font produces comparable `FontData`.
pub fn sample_font() -> Blob<u8> {
    static FONT_BLOB: std::sync::OnceLock<Blob<u8>> = std::sync::OnceLock::new();
    FONT_BLOB
        .get_or_init(|| Blob::new(std::sync::Arc::new(SAMPLE_FONT)))
        .clone()
}

/// A text context that sees only the embedded sample font.
pub fn sample_text_context() -> TextContext {
    TextContext::hermetic([sample_font()])
}

/// Text stack for sample scenes: an embedded-font-only parley context.
pub struct Samples {
    text: TextContext,
}

impl Samples {
    pub fn new() -> Self {
        Self {
            text: sample_text_context(),
        }
    }

    /// Lay out a paragraph in the embedded font, wrapped to `max_width`
    /// logical pixels.
    pub fn layout_text(
        &mut self,
        text: &str,
        font_size: f32,
        brush: guiduck_scene::paint::Brush,
        max_width: Option<f32>,
    ) -> Layout<guiduck_scene::paint::Brush> {
        let mut builder =
            self.text
                .layout_cx
                .ranged_builder(&mut self.text.font_cx, text, 1.0, true);
        builder.push_default(StyleProperty::FontFamily(FontFamily::named(
            SAMPLE_FONT_FAMILY,
        )));
        builder.push_default(StyleProperty::FontSize(font_size));
        builder.push_default(StyleProperty::Brush(brush));
        let mut layout = builder.build(text);
        layout.break_all_lines(max_width);
        layout.align(Alignment::Start, AlignmentOptions::default());
        layout
    }

    /// The full showcase scene: shapes, strokes, gradients, clips, layers,
    /// an image, transforms, a child fragment, and wrapped text.
    pub fn showcase(&mut self, store: &mut FragmentStore, viewport: Size) -> FragmentId {
        let mut root = Fragment::new();
        let width = viewport.width;

        // Title, wrapped to the viewport so resizing reflows it.
        let title = self.layout_text(
            "guiduck — one scene, two renderers. \
             The quick brown fox jumps over the lazy dog. 0123456789",
            18.0,
            css::BLACK.into(),
            Some((width - 32.0).max(50.0) as f32),
        );
        guiduck_core::text::append_layout(&mut root, &title, Point::new(16.0, 16.0));

        // Solid and gradient fills.
        root.fill(Rect::new(16.0, 96.0, 116.0, 156.0), css::REBECCA_PURPLE);
        root.fill(
            Rect::new(132.0, 96.0, 232.0, 156.0).to_rounded_rect(12.0),
            Gradient::new_linear((132.0, 96.0), (232.0, 156.0))
                .with_stops([css::ORANGE, css::CRIMSON]),
        );
        root.fill(
            Rect::new(248.0, 96.0, 308.0, 156.0).to_rounded_rect(30.0),
            Gradient::new_radial((278.0, 126.0), 30.0).with_stops([css::WHITE, css::STEEL_BLUE]),
        );

        // Dashed stroke and a self-intersecting star filled even-odd.
        let mut dashed = Stroke::new(3.0);
        dashed.dash_pattern = [8.0, 4.0].as_slice().into();
        root.stroke(
            Rect::new(324.0, 96.0, 424.0, 156.0).to_rounded_rect(8.0),
            css::DARK_GREEN,
            dashed,
        );
        root.fill_with_rule(
            pentagram(Point::new(490.0, 126.0), 38.0),
            css::GOLDENROD,
            Fill::EvenOdd,
        );

        // Clip: diagonal stripes visible only inside a rounded rect.
        root.push_clip(Rect::new(16.0, 180.0, 216.0, 260.0).to_rounded_rect(16.0));
        for i in 0..20 {
            let x = 16.0 + i as f64 * 12.0;
            root.push_transform(Affine::translate((x, 180.0)) * Affine::skew(0.5, 0.0));
            root.fill(Rect::new(0.0, 0.0, 6.0, 80.0), css::TEAL);
            root.pop_transform();
        }
        root.pop_clip();

        // Layer: overlapping circles composited at half opacity with multiply.
        root.fill(circle(Point::new(280.0, 220.0), 36.0), css::TOMATO);
        root.push_layer(
            0.5,
            BlendMode::new(Mix::Multiply, Compose::SrcOver),
            Rect::new(232.0, 180.0, 380.0, 260.0),
        );
        root.fill(circle(Point::new(320.0, 220.0), 36.0), css::CORNFLOWER_BLUE);
        root.pop_layer();

        // Procedural checkerboard image, scaled into its destination.
        root.image(checkerboard(), Rect::new(408.0, 180.0, 528.0, 260.0));

        // A child fragment placed twice with different transforms, proving
        // fragment composition and reuse.
        let badge = store.insert(badge_fragment());
        root.child(Affine::translate((16.0, 284.0)), badge);
        root.child(
            Affine::translate((160.0, 284.0)) * Affine::rotate(0.35) * Affine::scale(0.8),
            badge,
        );

        // Bottom-right anchor proving resize handling.
        root.fill(
            Rect::new(
                viewport.width - 40.0,
                viewport.height - 40.0,
                viewport.width - 16.0,
                viewport.height - 16.0,
            )
            .to_rounded_rect(6.0),
            css::MEDIUM_VIOLET_RED,
        );

        store.insert(root)
    }
}

impl Default for Samples {
    fn default() -> Self {
        Self::new()
    }
}

/// A small fragment used as a reusable child in sample scenes.
fn badge_fragment() -> Fragment {
    let mut badge = Fragment::new();
    badge.fill(
        Rect::new(0.0, 0.0, 120.0, 48.0).to_rounded_rect(24.0),
        Gradient::new_linear((0.0, 0.0), (120.0, 48.0))
            .with_stops([css::SLATE_BLUE, css::DARK_ORCHID]),
    );
    badge.stroke(
        Rect::new(2.0, 2.0, 118.0, 46.0).to_rounded_rect(22.0),
        css::WHITE,
        Stroke::new(2.0),
    );
    badge
}

fn circle(center: Point, radius: f64) -> guiduck_scene::Shape {
    guiduck_scene::Shape::Path(guiduck_scene::geom::Circle::new(center, radius).to_path(0.1))
}

/// A pentagram drawn by connecting alternate vertices of a pentagon. The
/// strokes cross each other, so an even-odd fill leaves the central pentagon
/// empty while non-zero would fill it — making the fill rule visible.
fn pentagram(center: Point, radius: f64) -> BezPath {
    let mut path = BezPath::new();
    let vertex = |i: u32| {
        let angle =
            (i * 2) as f64 * (2.0 * std::f64::consts::PI / 5.0) - std::f64::consts::FRAC_PI_2;
        Point::new(
            center.x + radius * angle.cos(),
            center.y + radius * angle.sin(),
        )
    };
    path.move_to(vertex(0));
    for i in 1..5 {
        path.line_to(vertex(i));
    }
    path.close_path();
    path
}

/// An 8x8 checkerboard as image data.
pub(crate) fn checkerboard() -> ImageBrush {
    const CELLS: u32 = 8;
    const CELL_PX: u32 = 4;
    const SIZE: u32 = CELLS * CELL_PX;
    let mut data = Vec::with_capacity((SIZE * SIZE * 4) as usize);
    for y in 0..SIZE {
        for x in 0..SIZE {
            let on = ((x / CELL_PX) + (y / CELL_PX)) % 2 == 0;
            let [r, g, b] = if on { [30, 30, 30] } else { [220, 220, 220] };
            data.extend_from_slice(&[r, g, b, 255]);
        }
    }
    ImageBrush::from(ImageData {
        data: Blob::new(std::sync::Arc::new(data)),
        format: ImageFormat::Rgba8,
        alpha_type: ImageAlphaType::Alpha,
        width: SIZE,
        height: SIZE,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn showcase_builds_balanced_fragments() {
        let mut samples = Samples::new();
        let mut store = FragmentStore::new();
        let root = samples.showcase(&mut store, Size::new(560.0, 360.0));
        let fragment = store.get(root).unwrap();
        assert!(fragment.is_balanced());
        assert!(!fragment.items.is_empty());
    }

    #[test]
    fn text_layout_uses_embedded_font() {
        let mut samples = Samples::new();
        let layout = samples.layout_text("hello", 16.0, css::BLACK.into(), None);
        assert!(layout.width() > 0.0);
        let mut fragment = Fragment::new();
        guiduck_core::text::append_layout(&mut fragment, &layout, Point::ORIGIN);
        assert!(
            fragment
                .items
                .iter()
                .any(|i| matches!(i, guiduck_scene::DisplayItem::GlyphRun(_))),
            "expected at least one glyph run"
        );
    }
}