aura-gui 0.2.2

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.
//! Una ventana de Aura lista para usar: ventana de winit, GPU, árbol de UI y accesibilidad. Traduce los eventos
//! de la ventana, pinta solo cuando se puede ver y suelta cachés al quedar tapada. No sabe nada de la aplicación.

use crate::app::input;
pub use crate::app::options::SurfaceOptions;
use crate::app::safe_area;
use crate::app::touch::TouchState;
use crate::app::visibility::FrameVisibility;
use crate::app::wake::{ProxyWaker, Wake};
use crate::core::{CursorStyle, InputEvent, Point, Size, px};
use crate::gpu::Renderer;
use crate::view::window::Window as UiWindow;
use std::sync::Arc;
use std::time::Instant;
use winit::dpi::{LogicalPosition, LogicalSize};
use winit::event::{ElementState, Ime, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoopProxy};
use winit::keyboard::ModifiersState;
use winit::window::{Window, WindowAttributes, WindowId};

/// Píxeles por «línea» de las ruedas que no informan en píxeles.
const LINE_SCROLL: f32 = 60.0;
/// En móvil el teclado del sistema solo aparece con un campo de texto enfocado.
const MOBILE: bool = cfg!(any(target_os = "android", target_os = "ios"));

pub struct Surface {
    pub window: Arc<Window>,
    pub renderer: Renderer,
    pub ui: UiWindow,
    pub access: accesskit_winit::Adapter,
    pub modifiers: ModifiersState,
    pub visibility: FrameVisibility,
    pub frames_drawn: u32,
    pub touch: TouchState,
    cursor: CursorStyle,
    ime_allowed: bool,
    /// Último frame en que se vaciaron los atlas por falta de espacio.
    atlas_reset_frame: u32,
}

impl Surface {
    pub fn create(
        event_loop: &ActiveEventLoop,
        proxy: &EventLoopProxy<Wake>,
        options: SurfaceOptions,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        let (width, height) = options.size;
        let attributes = Window::default_attributes().with_title(&options.title);
        // En móvil la ventana es la pantalla entera.
        let attributes = if MOBILE { attributes } else { attributes.with_inner_size(LogicalSize::new(width, height)) };
        #[cfg(target_os = "macos")]
        let attributes = if options.custom_titlebar {
            use winit::platform::macos::WindowAttributesExtMacOS;
            attributes.with_titlebar_transparent(true).with_fullsize_content_view(true).with_title_hidden(true)
        } else {
            attributes
        };
        let mut ui = UiWindow::new(options.theme, Size::new(px(width as f32), px(height as f32)), 1.0);
        ui.rem_size = options.rem_size;
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
        if options.system_clipboard {
            ui.entities.clipboard = Box::new(crate::app::clipboard::SystemClipboard::new());
        }
        Self::attach(event_loop, proxy, attributes, ui)
    }

    /// Abre otra vez la ventana con la UI y su estado (Android destruye la ventana al pasar a segundo plano).
    pub fn reopen(
        event_loop: &ActiveEventLoop,
        proxy: &EventLoopProxy<Wake>,
        ui: UiWindow,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        Self::attach(event_loop, proxy, Window::default_attributes(), ui)
    }

    /// Suelta ventana y GPU y se queda solo con la UI, para [`Surface::reopen`].
    pub fn into_ui(mut self) -> UiWindow {
        self.ui.shrink_caches();
        self.ui
    }

    fn attach(
        event_loop: &ActiveEventLoop,
        proxy: &EventLoopProxy<Wake>,
        attributes: WindowAttributes,
        mut ui: UiWindow,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        // El adaptador de accesibilidad se crea con la ventana aún oculta.
        let window = Arc::new(event_loop.create_window(attributes.with_visible(false))?);
        let access = accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
        window.set_visible(true);
        #[cfg(target_os = "ios")]
        crate::app::ios_scene::adopt(&window);
        window.set_ime_allowed(!MOBILE);
        let size = safe_area::surface_size(&window);
        let renderer = crate::app::renderer_choice::create(event_loop, &window, (size.width, size.height))?;
        let scale = window.scale_factor() as f32;
        ui.resize(Size::new(px(size.width as f32 / scale), px(size.height as f32 / scale)), scale);
        ui.set_safe_area(safe_area::insets(&window));
        ui.entities.inbox.set_waker(Arc::new(ProxyWaker { proxy: proxy.clone(), window: window.id() }));
        Ok(Self {
            window,
            renderer,
            ui,
            access,
            modifiers: ModifiersState::default(),
            visibility: FrameVisibility::new(size.width, size.height),
            frames_drawn: 0,
            touch: TouchState::default(),
            cursor: CursorStyle::Arrow,
            ime_allowed: !MOBILE,
            atlas_reset_frame: 0,
        })
    }

    pub fn id(&self) -> WindowId {
        self.window.id()
    }

    pub fn scale(&self) -> f32 {
        self.window.scale_factor() as f32
    }

    /// Pide un frame solo si la ventana se ve: tapada no gasta GPU.
    pub fn request_redraw(&self) {
        if self.visibility.is_drawable() {
            self.window.request_redraw();
        }
    }

    fn logical(&self, x: f64, y: f64) -> Point {
        let scale = self.scale() as f64;
        Point::new(px((x / scale) as f32), px((y / scale) as f32))
    }

    /// Eventos comunes a cualquier app. Devuelve `true` si hay que redibujar; los que no conoce dan `false`.
    pub fn handle(&mut self, event: WindowEvent) -> bool {
        match event {
            WindowEvent::Resized(_) => {
                let (size, scale) = (safe_area::surface_size(&self.window), self.scale());
                self.ui.set_safe_area(safe_area::insets(&self.window));
                self.visibility.resize(size.width, size.height);
                if self.visibility.is_drawable() {
                    self.renderer.resize(size.width, size.height);
                }
                self.ui.resize(Size::new(px(size.width as f32 / scale), px(size.height as f32 / scale)), scale);
                true
            }
            WindowEvent::ScaleFactorChanged { .. } | WindowEvent::Focused(_) => true,
            WindowEvent::Occluded(occluded) => {
                let changed = self.visibility.set_occluded(occluded);
                if changed && occluded {
                    self.ui.shrink_caches();
                    self.renderer.shrink_caches();
                } else if changed && self.visibility.is_drawable() {
                    let size = safe_area::surface_size(&self.window);
                    self.renderer.resize(size.width, size.height);
                }
                changed && !occluded
            }
            WindowEvent::ModifiersChanged(modifiers) => {
                self.modifiers = modifiers.state();
                self.ui.set_modifiers(input::modifiers(self.modifiers));
                false
            }
            WindowEvent::CursorMoved { position, .. } => {
                let position = self.logical(position.x, position.y);
                self.ui.handle_event(&InputEvent::MouseMove { position })
            }
            WindowEvent::MouseInput { state, button, .. } => {
                let Some(button) = input::mouse_button(button) else { return false };
                let position = self.ui.mouse_position();
                let event = match state {
                    ElementState::Pressed => InputEvent::MouseDown { position, button },
                    ElementState::Released => InputEvent::MouseUp { position, button },
                };
                self.ui.handle_event(&event)
            }
            WindowEvent::MouseWheel { delta, .. } => {
                let scale = self.scale() as f64;
                let (x, y) = match delta {
                    MouseScrollDelta::LineDelta(x, y) => (x * LINE_SCROLL, y * LINE_SCROLL),
                    MouseScrollDelta::PixelDelta(pixels) => ((pixels.x / scale) as f32, (pixels.y / scale) as f32),
                };
                let position = self.ui.mouse_position();
                self.ui.handle_event(&InputEvent::Scroll { position, delta: Point::new(px(x), px(y)) })
            }
            WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
                let Some(keystroke) = input::keystroke(&event, self.modifiers) else { return false };
                let text = event.text.as_ref().map(|text| text.to_string());
                self.ui.handle_event(&InputEvent::KeyDown { keystroke, text })
            }
            WindowEvent::Ime(Ime::Preedit(text, cursor)) => {
                self.ui.handle_event(&InputEvent::ImePreedit { text, cursor: cursor.map(|(start, _)| start) })
            }
            WindowEvent::Ime(Ime::Commit(text)) => self.ui.handle_event(&InputEvent::ImeCommit(text)),
            WindowEvent::Touch(touch) => {
                let position = self.logical(touch.location.x, touch.location.y);
                let events = self.touch.touch(touch.phase, touch.id, position, Instant::now());
                self.dispatch(&events)
            }
            _ => false,
        }
    }

    /// Envía eventos sintetizados (táctil, pulsación larga). `true` si alguno pide redibujar.
    pub fn dispatch(&mut self, events: &[InputEvent]) -> bool {
        events.iter().fold(false, |redraw, event| self.ui.handle_event(event) | redraw)
    }

    /// Hay que seguir pintando: una animación de la UI o la inercia de un deslizamiento.
    pub fn wants_frame(&mut self) -> bool {
        self.touch.is_flinging() || self.ui.wants_animation_frame()
    }

    /// Dibuja el árbol, lo envía a la GPU y sincroniza IME, cursor y accesibilidad.
    pub fn redraw(&mut self) -> Result<(), crate::gpu::context::Error> {
        let scale = self.scale();
        if let Some(scroll) = self.touch.step_fling(Instant::now()) {
            self.ui.handle_event(&scroll);
        }
        self.ui.draw(Some(self.renderer.glyph_target()));
        let clear = self.ui.theme.canvas;
        self.renderer.render(self.ui.scene(), scale, clear)?;
        let area = self.ui.ime_area();
        if MOBILE && area.is_some() != self.ime_allowed {
            self.ime_allowed = area.is_some();
            self.window.set_ime_allowed(self.ime_allowed);
        }
        if let Some(area) = area {
            self.window.set_ime_cursor_area(
                LogicalPosition::new(area.origin.x.0, area.origin.y.0),
                LogicalSize::new(area.size.width.0, area.size.height.0),
            );
        }
        self.frames_drawn += 1;
        self.relieve_atlas_overflow();
        self.sync_cursor();
        self.publish_access();
        Ok(())
    }

    /// Si un atlas se llenó, los glifos que no cupieron no se pintaron: se vacían y se repinta. Con dos frames
    /// seguidos desbordando (una pantalla que no cabe) se deja de reintentar.
    fn relieve_atlas_overflow(&mut self) {
        if !self.renderer.take_atlas_overflow() || self.frames_drawn.saturating_sub(self.atlas_reset_frame) <= 2 {
            return;
        }
        self.atlas_reset_frame = self.frames_drawn;
        self.ui.shrink_caches();
        self.renderer.shrink_caches();
        self.request_redraw();
    }

    /// Los hitboxes cambian en cada frame: tras dibujar se aplica la forma pedida.
    fn sync_cursor(&mut self) {
        let cursor = self.ui.cursor_style();
        if cursor != self.cursor {
            self.cursor = cursor;
            self.window.set_cursor(input::cursor_icon(cursor));
        }
    }

    /// Mientras ningún lector de pantalla esté activo no se construye nada: el árbol se registra tras
    /// `InitialTreeRequested` y se deja de registrar al desactivarse.
    pub fn access_event(&mut self, event: accesskit_winit::WindowEvent) {
        match event {
            accesskit_winit::WindowEvent::InitialTreeRequested => {
                self.ui.set_accessibility(true);
                // Un frame sin pintar basta para que los elementos registren sus nodos.
                self.ui.draw(None);
                self.publish_access();
            }
            accesskit_winit::WindowEvent::ActionRequested(request) => {
                if self.ui.accessibility_action(&request) {
                    self.request_redraw();
                }
            }
            accesskit_winit::WindowEvent::AccessibilityDeactivated => self.ui.set_accessibility(false),
        }
    }

    /// Envía el árbol del último frame; el adaptador lo ignora si no hay lector activo.
    pub fn publish_access(&mut self) {
        if self.ui.accessibility_active() {
            let ui = &self.ui;
            self.access.update_if_active(|| ui.accessibility_tree());
        }
    }
}