aura-gui 0.3.5

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Lienzo de la CPU: píxeles `0x00RRGGBB` (el formato que presenta softbuffer) y las operaciones comunes a todas
//! las primitivas. Las coordenadas de la escena son lógicas; cada píxel físico se evalúa en su centro, como la GPU.

use crate::core::Rgba;
use std::ops::Range;

pub struct Canvas<'a> {
    pub pixels: &'a mut [u32],
    pub width: u32,
    pub height: u32,
    pub scale: f32,
}

/// Color con alfa premultiplicado, en 0..=1.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Premultiplied {
    pub r: f32,
    pub g: f32,
    pub b: f32,
    pub a: f32,
}

impl Premultiplied {
    pub fn from_straight(color: Rgba) -> Self {
        Self { r: color.r * color.a, g: color.g * color.a, b: color.b * color.a, a: color.a }
    }

    pub fn scaled(self, factor: f32) -> Self {
        Self { r: self.r * factor, g: self.g * factor, b: self.b * factor, a: self.a * factor }
    }

    pub fn mix(self, other: Self, t: f32) -> Self {
        let lerp = |from: f32, to: f32| from + (to - from) * t;
        Self { r: lerp(self.r, other.r), g: lerp(self.g, other.g), b: lerp(self.b, other.b), a: lerp(self.a, other.a) }
    }
}

pub fn pack(red: f32, green: f32, blue: f32) -> u32 {
    let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
    (channel(red) << 16) | (channel(green) << 8) | channel(blue)
}

fn unpack(pixel: u32) -> (f32, f32, f32) {
    let channel = |shift: u32| ((pixel >> shift) & 0xff) as f32 / 255.0;
    (channel(16), channel(8), channel(0))
}

/// `src + dst · (1 − src.a)`: la misma mezcla que `PREMULTIPLIED_ALPHA_BLENDING` en la GPU.
#[inline]
pub fn blend(destination: u32, source: Premultiplied) -> u32 {
    if source.a >= 0.999 {
        return pack(source.r, source.g, source.b);
    }
    if source.a <= 0.001 {
        return destination;
    }
    let (red, green, blue) = unpack(destination);
    let keep = 1.0 - source.a;
    pack(source.r + red * keep, source.g + green * keep, source.b + blue * keep)
}

impl Canvas<'_> {
    pub fn clear(&mut self, color: Rgba) {
        self.pixels.fill(pack(color.r, color.g, color.b));
    }

    /// Píxeles cuyo centro cae en `[low, high]` (lógico) a lo largo de un eje de `limit` píxeles.
    fn span(&self, low: f32, high: f32, limit: u32) -> Range<u32> {
        let first = (low * self.scale - 0.5).ceil().max(0.0) as u32;
        let last = ((high * self.scale - 0.5).floor() + 1.0).clamp(0.0, limit as f32) as u32;
        first.min(last)..last
    }

    /// Filas y columnas a recorrer: el área de la primitiva (con `margin` lógico) recortada por `clip`, así el
    /// bucle interior no comprueba el recorte píxel a píxel.
    pub fn area(&self, bounds: [f32; 4], margin: f32, clip: [f32; 4]) -> (Range<u32>, Range<u32>) {
        let left = (bounds[0] - margin).max(clip[0]);
        let top = (bounds[1] - margin).max(clip[1]);
        let right = (bounds[0] + bounds[2] + margin).min(clip[0] + clip[2]);
        let bottom = (bounds[1] + bounds[3] + margin).min(clip[1] + clip[3]);
        (self.span(left, right, self.width), self.span(top, bottom, self.height))
    }

    /// Centro lógico de un píxel físico.
    #[inline]
    pub fn logical(&self, pixel: u32) -> f32 {
        (pixel as f32 + 0.5) / self.scale
    }

    /// Relleno opaco de un tramo de fila: escribe el píxel ya empaquetado, sin mezclar (casi un `memset`).
    pub fn fill_span(&mut self, y: u32, columns: Range<u32>, packed: u32) {
        let row = (y * self.width) as usize;
        self.pixels[row + columns.start as usize..row + columns.end as usize].fill(packed);
    }

    #[inline]
    pub fn blend_at(&mut self, x: u32, y: u32, source: Premultiplied) {
        let index = (y * self.width + x) as usize;
        self.pixels[index] = blend(self.pixels[index], source);
    }
}

/// Distancia con signo a un rectángulo redondeado (radios tl, tr, br, bl), igual que en el shader.
#[inline]
pub fn rounded_rect_distance(point: (f32, f32), half: (f32, f32), radii: [f32; 4]) -> f32 {
    let radius = match (point.0 > 0.0, point.1 > 0.0) {
        (false, false) => radii[0],
        (true, false) => radii[1],
        (true, true) => radii[2],
        (false, true) => radii[3],
    };
    let qx = point.0.abs() - half.0 + radius;
    let qy = point.1.abs() - half.1 + radius;
    qx.max(qy).min(0.0) + (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt() - radius
}

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

    #[test]
    fn la_mezcla_premultiplicada_coincide_con_la_gpu() {
        let white = pack(1.0, 1.0, 1.0);
        let half_black = Premultiplied { r: 0.0, g: 0.0, b: 0.0, a: 0.5 };
        assert_eq!(blend(white, half_black), pack(0.5, 0.5, 0.5));
        assert_eq!(blend(white, Premultiplied { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }), 0xff0000);
        assert_eq!(blend(white, Premultiplied { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), white);
    }

    #[test]
    fn los_tramos_usan_el_centro_del_pixel_y_el_recorte() {
        let mut pixels = vec![0; 100];
        let canvas = Canvas { pixels: &mut pixels, width: 10, height: 10, scale: 2.0 };
        // Rectángulo lógico 1..3 a escala 2: centros físicos 2.5..5.5 → píxeles 2..6.
        let (columns, rows) = canvas.area([1.0, 1.0, 2.0, 2.0], 0.0, [0.0, 0.0, 100.0, 100.0]);
        assert_eq!((columns, rows), (2..6, 2..6));
        let (columns, _) = canvas.area([1.0, 1.0, 2.0, 2.0], 0.0, [0.0, 0.0, 2.0, 100.0]);
        assert_eq!(columns, 2..4, "el recorte acota antes de pintar");
        assert!(rounded_rect_distance((0.0, 0.0), (2.0, 2.0), [1.0; 4]) < 0.0);
        assert!(rounded_rect_distance((1.9, 1.9), (2.0, 2.0), [1.0; 4]) > 0.0, "fuera de la esquina redonda");
    }
}