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.
//! Gestos táctiles (Android, iOS, pantallas táctiles) traducidos a los eventos de ratón que ya entiende la UI:
//!
//! - **Tocar**: `MouseDown` + `MouseUp` en el punto, es decir, un clic.
//! - **Deslizar**: `Scroll` siguiendo el dedo y, al soltar con velocidad, inercia que frena sola.
//! - **Mantener pulsado** ([`LONG_PRESS`]) y mover: arrastre con el botón izquierdo (selección, divisores, drag).
//!
//! Solo cuenta el primer dedo; los demás se ignoran. La inercia pide frames solo mientras se mueve: con el dedo
//! quieto o la lista parada, el bucle vuelve a dormir.

use crate::core::{InputEvent, MouseButton, Point, px};
use std::time::{Duration, Instant};
use winit::event::TouchPhase;

/// Distancia (px lógicos) que el dedo puede moverse y seguir siendo un toque.
const SLOP: f32 = 10.0;
/// Tiempo quieto tras el que el toque pasa a ser una pulsación que arrastra.
pub const LONG_PRESS: Duration = Duration::from_millis(400);
/// Frenado de la inercia por segundo (como `UIScrollView` normal: la velocidad cae a ~13% en un segundo).
const FRICTION: f32 = 2.0;
/// Por debajo de esta velocidad (px lógicos/s) no se lanza ni se mantiene la inercia.
const MIN_FLING: f32 = 40.0;
/// Fuera de cualquier elemento: quita el hover al levantar el dedo.
const NOWHERE: Point = Point { x: px(-1.0e6), y: px(-1.0e6) };

#[derive(Clone, Copy, PartialEq)]
enum Mode {
    /// Aún puede ser un toque, un deslizamiento o una pulsación larga.
    Pending,
    Pan,
    Press,
}

struct Finger {
    id: u64,
    mode: Mode,
    start: Point,
    last: Point,
    started_at: Instant,
    moved_at: Instant,
    /// Velocidad suavizada en px lógicos por segundo.
    velocity: Point,
}

struct Fling {
    at: Point,
    velocity: Point,
    last: Instant,
}

#[derive(Default)]
pub struct TouchState {
    finger: Option<Finger>,
    fling: Option<Fling>,
}

fn distance(a: Point, b: Point) -> f32 {
    (a.x.0 - b.x.0).hypot(a.y.0 - b.y.0)
}

impl TouchState {
    /// Traduce un evento táctil (posición ya en px lógicos) a eventos de puntero.
    pub fn touch(&mut self, phase: TouchPhase, id: u64, position: Point, now: Instant) -> Vec<InputEvent> {
        let left = MouseButton::Left;
        match phase {
            TouchPhase::Started if self.finger.is_none() => {
                self.fling = None;
                self.finger = Some(Finger {
                    id,
                    mode: Mode::Pending,
                    start: position,
                    last: position,
                    started_at: now,
                    moved_at: now,
                    velocity: Point::new(px(0.0), px(0.0)),
                });
                vec![InputEvent::MouseMove { position }]
            }
            TouchPhase::Moved => {
                let mut events = self.tick(now);
                let Some(finger) = self.finger.as_mut().filter(|finger| finger.id == id) else { return events };
                if finger.mode == Mode::Pending && distance(position, finger.start) > SLOP {
                    finger.mode = Mode::Pan;
                }
                match finger.mode {
                    Mode::Pan => {
                        let delta = Point::new(position.x - finger.last.x, position.y - finger.last.y);
                        let dt = now.saturating_duration_since(finger.moved_at).as_secs_f32().max(1.0e-3);
                        finger.velocity = Point::new(
                            px(finger.velocity.x.0 * 0.6 + delta.x.0 / dt * 0.4),
                            px(finger.velocity.y.0 * 0.6 + delta.y.0 / dt * 0.4),
                        );
                        events.push(InputEvent::Scroll { position: finger.start, delta });
                    }
                    Mode::Press => events.push(InputEvent::MouseMove { position }),
                    Mode::Pending => {}
                }
                finger.last = position;
                finger.moved_at = now;
                events
            }
            TouchPhase::Ended | TouchPhase::Cancelled => {
                let Some(finger) = self.finger.take_if(|finger| finger.id == id) else { return Vec::new() };
                let cancelled = phase == TouchPhase::Cancelled;
                let mut events = match finger.mode {
                    Mode::Pending if !cancelled => vec![
                        InputEvent::MouseDown { position, button: left },
                        InputEvent::MouseUp { position, button: left },
                    ],
                    Mode::Press => {
                        vec![InputEvent::MouseUp { position: if cancelled { NOWHERE } else { position }, button: left }]
                    }
                    Mode::Pan if !cancelled => {
                        // Un dedo parado antes de soltar no lanza la lista.
                        let still = now.saturating_duration_since(finger.moved_at) > Duration::from_millis(80);
                        let speed = finger.velocity.x.0.hypot(finger.velocity.y.0);
                        if !still && speed > MIN_FLING {
                            self.fling = Some(Fling { at: finger.start, velocity: finger.velocity, last: now });
                        }
                        Vec::new()
                    }
                    _ => Vec::new(),
                };
                events.push(InputEvent::MouseMove { position: NOWHERE });
                events
            }
            _ => Vec::new(),
        }
    }

    /// Convierte en pulsación larga un dedo que lleva quieto [`LONG_PRESS`].
    pub fn tick(&mut self, now: Instant) -> Vec<InputEvent> {
        match self.finger.as_mut() {
            Some(finger) if finger.mode == Mode::Pending && now >= finger.started_at + LONG_PRESS => {
                finger.mode = Mode::Press;
                vec![InputEvent::MouseDown { position: finger.start, button: MouseButton::Left }]
            }
            _ => Vec::new(),
        }
    }

    /// Cuándo despertar para detectar la pulsación larga.
    pub fn next_deadline(&self) -> Option<Instant> {
        self.finger.as_ref().filter(|finger| finger.mode == Mode::Pending).map(|finger| finger.started_at + LONG_PRESS)
    }

    pub fn is_flinging(&self) -> bool {
        self.fling.is_some()
    }

    /// Avanza la inercia hasta `now` y devuelve el desplazamiento de este frame.
    pub fn step_fling(&mut self, now: Instant) -> Option<InputEvent> {
        let fling = self.fling.as_mut()?;
        let dt = now.saturating_duration_since(fling.last).as_secs_f32().min(0.1);
        fling.last = now;
        let decay = (-FRICTION * dt).exp();
        fling.velocity = Point::new(px(fling.velocity.x.0 * decay), px(fling.velocity.y.0 * decay));
        let delta = Point::new(px(fling.velocity.x.0 * dt), px(fling.velocity.y.0 * dt));
        let event = InputEvent::Scroll { position: fling.at, delta };
        if fling.velocity.x.0.hypot(fling.velocity.y.0) < MIN_FLING {
            self.fling = None;
        }
        Some(event)
    }
}

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

    fn at(x: f32, y: f32) -> Point {
        Point::new(px(x), px(y))
    }

    fn is_click(events: &[InputEvent]) -> bool {
        matches!(events, [InputEvent::MouseDown { .. }, InputEvent::MouseUp { .. }, InputEvent::MouseMove { .. }])
    }

    #[test]
    fn a_short_tap_is_a_click() {
        let mut touch = TouchState::default();
        let now = Instant::now();
        touch.touch(TouchPhase::Started, 1, at(10.0, 10.0), now);
        touch.touch(TouchPhase::Moved, 1, at(13.0, 12.0), now + Duration::from_millis(30));
        assert!(is_click(&touch.touch(TouchPhase::Ended, 1, at(13.0, 12.0), now + Duration::from_millis(60))));
    }

    #[test]
    fn swiping_scrolls_and_keeps_moving_after_release() {
        let mut touch = TouchState::default();
        let mut now = Instant::now();
        touch.touch(TouchPhase::Started, 1, at(50.0, 300.0), now);
        for step in 1..=5 {
            now += Duration::from_millis(16);
            let events = touch.touch(TouchPhase::Moved, 1, at(50.0, 300.0 - step as f32 * 30.0), now);
            assert!(matches!(events.as_slice(), [] | [InputEvent::Scroll { .. }]));
        }
        assert!(!is_click(&touch.touch(TouchPhase::Ended, 1, at(50.0, 150.0), now)));
        assert!(touch.is_flinging());
        let Some(InputEvent::Scroll { delta, .. }) = touch.step_fling(now + Duration::from_millis(16)) else {
            panic!()
        };
        assert!(delta.y.0 < 0.0, "sigue en la dirección del dedo");
        for frame in 2..400 {
            touch.step_fling(now + Duration::from_millis(16 * frame));
        }
        assert!(!touch.is_flinging(), "la inercia termina sola");
    }

    #[test]
    fn holding_still_turns_into_a_drag() {
        let mut touch = TouchState::default();
        let now = Instant::now();
        touch.touch(TouchPhase::Started, 1, at(10.0, 10.0), now);
        assert_eq!(touch.next_deadline(), Some(now + LONG_PRESS));
        assert!(matches!(touch.tick(now + LONG_PRESS).as_slice(), [InputEvent::MouseDown { .. }]));
        let moved = touch.touch(TouchPhase::Moved, 1, at(80.0, 10.0), now + LONG_PRESS * 2);
        assert!(matches!(moved.as_slice(), [InputEvent::MouseMove { .. }]));
        let ended = touch.touch(TouchPhase::Ended, 1, at(80.0, 10.0), now + LONG_PRESS * 2);
        assert!(matches!(ended.as_slice(), [InputEvent::MouseUp { .. }, InputEvent::MouseMove { .. }]));
    }

    #[test]
    fn a_second_finger_is_ignored() {
        let mut touch = TouchState::default();
        let now = Instant::now();
        touch.touch(TouchPhase::Started, 1, at(10.0, 10.0), now);
        assert!(touch.touch(TouchPhase::Started, 2, at(90.0, 90.0), now).is_empty());
        assert!(touch.touch(TouchPhase::Ended, 2, at(90.0, 90.0), now).is_empty());
    }
}