guido 0.6.0

A reactive Rust GUI library using wgpu for Wayland layer shell widgets
Documentation
//! Shared textured vertex types for quad rendering.
//!
//! This module provides the common vertex format used by both text quad and image quad
//! rendering pipelines.

use crate::transform::Transform;
use wgpu::{VertexAttribute, VertexBufferLayout, VertexFormat, VertexStepMode};

/// Vertex with pre-computed NDC position, UV coordinates, and clip data.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct TexturedVertex {
    /// Position in NDC (pre-computed on CPU)
    pub position: [f32; 2],
    /// Texture coordinates
    pub uv: [f32; 2],
    /// This corner, in whatever space [`clip_rect`](Self::clip_rect) is
    /// written in.
    ///
    /// The clip test is an SDF against a rect, and the only thing it needs is
    /// for the point and the rect to be in one space. That space is the clip's
    /// own, for an image and for a transformed text alike, so a turned clip
    /// cuts the turned shape rather than the box around it.
    ///
    /// Mapping the four corners on the CPU is what keeps this free. The map is
    /// affine, and interpolating an affine function of position across a
    /// triangle gives the same answer as applying it to the interpolated
    /// position — so the shader needs no matrix, and the vertex no room for
    /// one.
    pub clip_pos: [f32; 2],
    /// Clip rect [x, y, width, height], in that same space.
    pub clip_rect: [f32; 4],
    /// Clip corner radii [top_left, top_right, bottom_right, bottom_left], in
    /// that same space. The shader reads all four — a rect clip passes zeros.
    pub clip_params: [f32; 4],
    /// `[curvature, _, _, _]`. Its own 16-byte slot because a `Float32x4` is
    /// the narrowest attribute that keeps the rest of the struct aligned, and
    /// there is no padding here to mine — unlike `ShapeInstance`, this is a
    /// per-*vertex* buffer with four attributes in it, nowhere near a limit.
    pub clip_curvature: [f32; 4],
}

/// The clip a textured quad is cut by, in the clip's own coordinates.
///
/// Four values that always travel together and always have to agree about
/// which space they are in, which is exactly the thing that is easy to get
/// wrong when they are four arguments in a row.
///
/// There used to be a second spelling — a plain box in physical world pixels —
/// because text was cut by the box around its clip rather than the clip. Both
/// quad pipelines now take [`shape`](Self::shape), so a turned clip cuts the
/// turned shape whichever of them is drawing.
#[derive(Clone, Copy, Debug)]
pub struct QuadClip {
    /// `[x, y, width, height]`. Negative width/height is the no-clip sentinel.
    pub rect: [f32; 4],
    /// `[top_left, top_right, bottom_right, bottom_left]`.
    pub radii: [f32; 4],
    /// Superellipse curvature (K-value), so a squircle clip cuts an image the
    /// way it cuts a rectangle. The shape shader has always taken this; this
    /// pipeline did not, and a squircle clip over an image cut with circular
    /// corners while the container beside it cut with squircular ones.
    pub curvature: f32,
    /// Physical world pixels into the space `rect` is written in. The identity
    /// where that space *is* physical world pixels, rather than an `Option` —
    /// "no map needed" and "no clip" are different facts, and `rect`'s sentinel
    /// is the one that answers the second.
    pub to_clip_space: Transform,
}

impl QuadClip {
    /// Nothing is cut.
    pub const NONE: Self = Self {
        rect: crate::renderer::gpu::NO_CLIP_RECT,
        radii: [0.0; 4],
        curvature: 1.0,
        to_clip_space: Transform::IDENTITY,
    };

    /// Everything is cut — a clip whose placement collapsed has no inside.
    pub(crate) const EMPTY: Self = Self {
        rect: crate::renderer::gpu::EMPTY_CLIP_RECT,
        radii: [0.0; 4],
        curvature: 1.0,
        to_clip_space: Transform::IDENTITY,
    };

    /// Cut to the clip's shape, in the coordinates the widget declared it in.
    pub fn shape(clip: &crate::shape::PlacedShape, scale: f32) -> Self {
        let Some(to_clip_space) = clip.to_local(scale) else {
            return Self::EMPTY;
        };
        let r = clip.rect;
        Self {
            rect: [r.x, r.y, r.width, r.height],
            radii: clip.radii.to_array(),
            curvature: clip.curvature,
            to_clip_space,
        }
    }

    /// This corner, in the clip's space.
    #[inline]
    pub(crate) fn place(&self, x: f32, y: f32) -> [f32; 2] {
        let (x, y) = self.to_clip_space.transform_point(x, y);
        [x, y]
    }
}

impl TexturedVertex {
    /// One corner of a quad: where it is on screen, where it is in the
    /// texture, and where it falls in its clip.
    ///
    /// `screen` is in physical pixels and `ndc` is that same point in clip
    /// space — both are wanted, so the caller passes both rather than having
    /// this undo one to get the other.
    #[inline]
    pub fn corner(ndc: [f32; 2], uv: [f32; 2], screen: (f32, f32), clip: &QuadClip) -> Self {
        Self {
            position: ndc,
            uv,
            clip_pos: clip.place(screen.0, screen.1),
            clip_rect: clip.rect,
            clip_params: clip.radii,
            clip_curvature: [clip.curvature, 0.0, 0.0, 0.0],
        }
    }

    pub fn desc() -> VertexBufferLayout<'static> {
        VertexBufferLayout {
            array_stride: std::mem::size_of::<TexturedVertex>() as u64,
            step_mode: VertexStepMode::Vertex,
            attributes: &[
                // position (NDC)
                VertexAttribute {
                    offset: 0,
                    shader_location: 0,
                    format: VertexFormat::Float32x2,
                },
                // uv
                VertexAttribute {
                    offset: 8,
                    shader_location: 1,
                    format: VertexFormat::Float32x2,
                },
                // clip_pos
                VertexAttribute {
                    offset: 16,
                    shader_location: 2,
                    format: VertexFormat::Float32x2,
                },
                // clip_rect
                VertexAttribute {
                    offset: 24,
                    shader_location: 3,
                    format: VertexFormat::Float32x4,
                },
                // clip_params
                VertexAttribute {
                    offset: 40,
                    shader_location: 4,
                    format: VertexFormat::Float32x4,
                },
                // clip_curvature
                VertexAttribute {
                    offset: 56,
                    shader_location: 5,
                    format: VertexFormat::Float32x4,
                },
            ],
        }
    }
}

/// Convert screen coordinates to NDC (Normalized Device Coordinates).
#[inline]
pub fn to_ndc(x: f32, y: f32, screen_width: f32, screen_height: f32) -> [f32; 2] {
    [
        (x / screen_width) * 2.0 - 1.0,
        1.0 - (y / screen_height) * 2.0,
    ]
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::renderer::CornerRadii;
    use crate::shape::PlacedShape;
    use crate::widgets::Rect;

    /// The rect stays in the logical units the widget declared and the scale is
    /// folded into the map instead. Getting that backwards scales twice or not
    /// at all, and the symptom is a HiDPI surface clipped to a quarter of its
    /// viewport.
    #[test]
    fn a_clip_is_scaled_by_its_map_and_not_by_its_rect() {
        let rect = Rect::new(10.0, 20.0, 100.0, 50.0);

        let shaped = QuadClip::shape(
            &PlacedShape {
                rect,
                radii: CornerRadii::uniform(4.0),
                curvature: 1.0,
                placement: crate::transform::Transform::IDENTITY,
            },
            2.0,
        );
        assert_eq!(
            shaped.rect,
            [10.0, 20.0, 100.0, 50.0],
            "the declaration, unscaled — the map is what carries the scale"
        );
        let [x, y] = shaped.place(20.0, 40.0);
        assert!(
            (x - 10.0).abs() < 0.01 && (y - 20.0).abs() < 0.01,
            "so the same physical corner arrives at the logical one, got ({x}, {y})"
        );
        assert_eq!(shaped.radii, [4.0; 4], "radii travel with the rect");
    }

    /// A clip whose placement collapses lets nothing through, rather than
    /// everything.
    #[test]
    fn a_collapsed_clip_cuts_it_all() {
        let collapsed = QuadClip::shape(
            &PlacedShape {
                rect: Rect::new(0.0, 0.0, 100.0, 50.0),
                radii: CornerRadii::uniform(0.0),
                curvature: 1.0,
                placement: crate::transform::Transform::scale(0.0),
            },
            1.0,
        );
        assert_eq!(collapsed.rect, crate::renderer::gpu::EMPTY_CLIP_RECT);
        assert_ne!(
            collapsed.rect,
            crate::renderer::gpu::NO_CLIP_RECT,
            "the sentinel for `clip everything`, not the one for `clip nothing`"
        );
    }
}