aura-gui 0.3.0

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.
//! Modo instantánea para pruebas visuales automatizadas.
//!
//! - `AURA_SNAPSHOT=salida.png` guarda un frame y cierra la app.
//! - `AURA_SNAPSHOT_KEYS="mod-shift-p,down,text:hola,wait:500"` simula pulsaciones (y pausas) antes de capturar.
//! - El puntero también se simula: `move:x:y`, `down:x:y` y `up:x:y` (px lógicos). Un arrastre a medias
//!   (`down`, `move`, sin `up`) deja ver las zonas de soltado en la captura.
//! - `AURA_SNAPSHOT_DELAY_MS=3000` espera (por ejemplo, a que responda el LSP).

use crate::core::{InputEvent, MouseButton, Point, px};
use crate::gpu::CapturedFrame;
use std::fs::File;
use std::io::{self, BufWriter};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

/// Evento de puntero de una tecla simulada (`down:120:50`, `rdown`/`rup` con el botón derecho); `None` si no lo es.
pub fn pointer_event(spec: &str) -> Option<InputEvent> {
    let mut parts = spec.split(':');
    let kind = parts.next()?;
    let position = Point::new(px(parts.next()?.parse().ok()?), px(parts.next()?.parse().ok()?));
    match kind {
        "move" => Some(InputEvent::MouseMove { position }),
        "down" => Some(InputEvent::MouseDown { position, button: MouseButton::Left }),
        "up" => Some(InputEvent::MouseUp { position, button: MouseButton::Left }),
        "rdown" => Some(InputEvent::MouseDown { position, button: MouseButton::Right }),
        "rup" => Some(InputEvent::MouseUp { position, button: MouseButton::Right }),
        _ => None,
    }
}

pub struct SnapshotRequest {
    pub path: PathBuf,
    pub keystrokes: Vec<String>,
    started: Instant,
    delay: Duration,
}

impl SnapshotRequest {
    pub fn from_environment() -> Option<Self> {
        let path = PathBuf::from(std::env::var_os("AURA_SNAPSHOT")?);
        let keystrokes = std::env::var("AURA_SNAPSHOT_KEYS")
            .map(|keys| keys.split(',').map(str::trim).filter(|key| !key.is_empty()).map(str::to_owned).collect())
            .unwrap_or_default();
        let delay = std::env::var("AURA_SNAPSHOT_DELAY_MS")
            .ok()
            .and_then(|value| value.parse().ok())
            .map_or(Duration::ZERO, Duration::from_millis);
        Some(Self { path, keystrokes, started: Instant::now(), delay })
    }

    pub fn deadline(&self) -> Instant {
        self.started + self.delay
    }

    pub fn ready(&self) -> bool {
        Instant::now() >= self.deadline()
    }
}

pub fn save_png(path: &Path, frame: &CapturedFrame) -> io::Result<()> {
    let mut encoder = png::Encoder::new(BufWriter::new(File::create(path)?), frame.width, frame.height);
    encoder.set_color(png::ColorType::Rgba);
    encoder.set_depth(png::BitDepth::Eight);
    let mut writer = encoder.write_header().map_err(io::Error::other)?;
    writer.write_image_data(&frame.rgba).map_err(io::Error::other)?;
    writer.finish().map_err(io::Error::other)
}

/// Lo que las pruebas visuales necesitan de una ventana: simular pulsaciones y guardar el frame.
impl crate::app::surface::Surface {
    /// Simula una pulsación para las pruebas visuales: `text:hola` escribe, `wait:500` espera atendiendo lo que
    /// llega de otros hilos y `down:x:y` / `move:x:y` / `up:x:y` mueven el puntero.
    pub fn press(&mut self, keystroke: &str) {
        if let Some(milliseconds) = keystroke.strip_prefix("wait:").and_then(|value| value.parse::<u64>().ok()) {
            let deadline = std::time::Instant::now() + std::time::Duration::from_millis(milliseconds);
            while std::time::Instant::now() < deadline {
                self.ui.run_pending();
                std::thread::sleep(std::time::Duration::from_millis(10));
            }
            self.ui.draw(None);
            return;
        }
        let event = match (pointer_event(keystroke), keystroke.strip_prefix("text:")) {
            (Some(event), _) => event,
            (None, Some(text)) => InputEvent::ImeCommit(text.to_owned()),
            (None, None) => {
                let text = (keystroke.chars().count() == 1).then(|| keystroke.to_owned());
                InputEvent::KeyDown { keystroke: keystroke.to_owned(), text }
            }
        };
        self.ui.handle_event(&event);
        self.ui.draw(None);
    }

    pub fn save_snapshot(&mut self, request: &SnapshotRequest) {
        let clear = self.ui.theme.canvas;
        let result = self
            .renderer
            .capture(self.ui.scene(), self.scale(), clear)
            .map_err(|error| error.to_string())
            .and_then(|frame| save_png(&request.path, &frame).map_err(|error| error.to_string()));
        match result {
            Ok(()) => crate::app::console::info(format_args!("instantánea guardada en {}", request.path.display())),
            Err(error) => crate::app::console::warn(format_args!("no se pudo guardar la instantánea: {error}")),
        }
    }
}

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

    #[test]
    fn snapshot_delay_has_a_fixed_deadline_without_polling_frames() {
        let started = Instant::now();
        let request = SnapshotRequest {
            path: PathBuf::from("unused.png"),
            keystrokes: Vec::new(),
            started,
            delay: Duration::from_secs(3600),
        };
        assert_eq!(request.deadline(), started + Duration::from_secs(3600));
        assert!(!request.ready());
        let immediate = SnapshotRequest { delay: Duration::ZERO, ..request };
        assert!(immediate.ready());
    }

    #[test]
    fn reconoce_los_eventos_de_puntero_y_descarta_el_resto() {
        assert!(matches!(pointer_event("down:12.5:40"), Some(InputEvent::MouseDown { .. })));
        assert!(matches!(pointer_event("move:1:2"), Some(InputEvent::MouseMove { .. })));
        assert!(matches!(pointer_event("up:1:2"), Some(InputEvent::MouseUp { .. })));
        assert!(pointer_event("mod-shift-p").is_none() && pointer_event("down:x:2").is_none());
        assert!(pointer_event("text:hola").is_none());
    }
}