//! Vista core — máquina de estados agnóstica del deck horizontal. //! //! Lógica pura: dados los eventos crudos de pointer (coords + viewport width //! + n_pages) decide cuándo arrancar drag horizontal, cuánto trasladar el //! strip y a qué página snapear al soltar. Sin DOM, sin wasm-bindgen. /// Umbral en pixels para confirmar gesto horizontal vs vertical. pub const DRAG_DECISION_PX: f64 = 8.0; /// Cuán más horizontal que vertical debe ser el delta para considerarse "swipe". pub const HORIZONTAL_BIAS: f64 = 1.3; #[derive(Clone, Debug, Default)] pub struct DeckState { pub current_index: usize, pointer_start: Option<(f64, f64, i32)>, drag_active: bool, drag_start_offset: f64, } #[derive(Clone, Debug, PartialEq)] pub enum DragOutcome { /// Aún no hay decisión — esperar más movimiento. Idle, /// Empezar drag horizontal: el host debe capturar el pointer. StartHorizontal { pointer_id: i32 }, /// Movimiento vertical predominante — host debe ceder al scroll nativo. CancelVertical, /// Drag en curso — host debe trasladar el strip a este offset. DragOffset(f64), } #[derive(Clone, Debug, PartialEq)] pub struct SnapResult { pub target_index: usize, pub offset_px: f64, pub changed: bool, } impl DeckState { pub fn new() -> Self { Self::default() } /// Marca el inicio de un gesto. `viewport_width` se usa para anclar el /// drag_start_offset a la página visible actual. pub fn pointer_down(&mut self, x: f64, y: f64, pointer_id: i32, viewport_width: f64) { self.pointer_start = Some((x, y, pointer_id)); self.drag_active = false; self.drag_start_offset = -(self.current_index as f64) * viewport_width; } /// Procesa un movimiento. Devuelve la acción que el host debe ejecutar. pub fn pointer_move(&mut self, x: f64, y: f64) -> DragOutcome { let Some((sx, sy, pid)) = self.pointer_start else { return DragOutcome::Idle; }; let dx = x - sx; let dy = y - sy; if !self.drag_active { let abs_dx = dx.abs(); let abs_dy = dy.abs(); if abs_dx > DRAG_DECISION_PX && abs_dx > abs_dy * HORIZONTAL_BIAS { self.drag_active = true; return DragOutcome::StartHorizontal { pointer_id: pid }; } else if abs_dy > DRAG_DECISION_PX { self.pointer_start = None; return DragOutcome::CancelVertical; } else { return DragOutcome::Idle; } } DragOutcome::DragOffset(self.drag_start_offset + dx) } /// Finaliza el gesto. Si había drag activo, calcula la página snap y /// actualiza `current_index`. `current_offset` viene del estado real /// del strip (el host lee CSS transform / variable). pub fn pointer_end( &mut self, current_offset: f64, viewport_width: f64, n_pages: usize, ) -> Option { let was_active = self.drag_active; self.drag_active = false; self.pointer_start = None; if !was_active || viewport_width <= 0.0 || n_pages == 0 { return None; } let raw = -current_offset / viewport_width; let target = (raw.round().max(0.0) as usize).min(n_pages - 1); let offset_px = -(target as f64) * viewport_width; let changed = self.current_index != target; self.current_index = target; Some(SnapResult { target_index: target, offset_px, changed }) } /// Salto programático (click en tabs externos). Devuelve el offset /// resultante para que el host lo aplique al strip. pub fn goto(&mut self, index: usize, viewport_width: f64) -> SnapResult { let offset_px = -(index as f64) * viewport_width; let changed = self.current_index != index; self.current_index = index; SnapResult { target_index: index, offset_px, changed } } /// Reposiciona tras un resize. Devuelve el offset que el host debe /// aplicar sin animación. pub fn reposition(&self, viewport_width: f64) -> f64 { -(self.current_index as f64) * viewport_width } } #[cfg(test)] mod tests { use super::*; #[test] fn vertical_drag_is_cancelled() { let mut s = DeckState::new(); s.pointer_down(100.0, 100.0, 1, 800.0); // Movimiento vertical mayor que el umbral. let r = s.pointer_move(100.0, 120.0); assert_eq!(r, DragOutcome::CancelVertical); } #[test] fn horizontal_drag_starts_after_threshold() { let mut s = DeckState::new(); s.pointer_down(100.0, 100.0, 7, 800.0); // Justo por debajo del umbral → Idle. assert_eq!(s.pointer_move(105.0, 100.0), DragOutcome::Idle); // Sobre el umbral con bias horizontal → Start. let r = s.pointer_move(120.0, 100.0); assert_eq!(r, DragOutcome::StartHorizontal { pointer_id: 7 }); } #[test] fn snap_rounds_to_nearest_page() { let mut s = DeckState::new(); s.current_index = 1; s.pointer_down(0.0, 0.0, 1, 1000.0); // drag_start_offset = -1000 // Forzar drag activo s.pointer_move(20.0, 0.0); // Offset actual = -1000 + 20 = -980 → target round(980/1000) = 1, sin cambio let r = s.pointer_end(-980.0, 1000.0, 3).unwrap(); assert_eq!(r.target_index, 1); assert!(!r.changed); // Mover lo suficiente para snapear a página 0 s.pointer_down(0.0, 0.0, 1, 1000.0); s.pointer_move(600.0, 0.0); let r = s.pointer_end(-400.0, 1000.0, 3).unwrap(); assert_eq!(r.target_index, 0); assert!(r.changed); } #[test] fn snap_clamps_to_bounds() { let mut s = DeckState::new(); s.current_index = 2; s.pointer_down(0.0, 0.0, 1, 500.0); s.pointer_move(50.0, 0.0); // Offset muy a la izquierda → debería clamp a n_pages-1 let r = s.pointer_end(-9999.0, 500.0, 3).unwrap(); assert_eq!(r.target_index, 2); } #[test] fn goto_updates_index_and_offset() { let mut s = DeckState::new(); let r = s.goto(2, 800.0); assert_eq!(r.target_index, 2); assert_eq!(r.offset_px, -1600.0); assert!(r.changed); // segundo goto al mismo índice → changed=false let r = s.goto(2, 800.0); assert!(!r.changed); } }