tawasuyu

tawasuyu/pluma

Multi-canvas authoring (bundle of bodies) + reactive notebook, in Rust, on Llimphi.

⎇ 30467600bcd9effa15e8338fb32d357c1edab660 00_unanchay/pluma/pluma-deck-core/src/lib.rs Rust · 195 siq'ikuna · 7.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat: pluma standalone — autoría multilienzo (haz de cuerpos) + notebook reactivo (front-door, git-dep al monorepo) 30467600b · Sergio ·

1//! Deck core — máquinas de estado agnósticas para presentar, en dos modos:
2//!
3//! - **Lineal** ([`DeckState`], este archivo): strip horizontal de páginas con
4//!   drag/snap. Dados los eventos crudos de pointer (coords + viewport width
5//!   + n_pages) decide cuándo arrancar drag horizontal, cuánto trasladar el
6//!   strip y a qué página snapear al soltar.
7//! - **Espacial** ([`recorrido`], tipo Prezi): lienzo 2D infinito con marcos en
8//!   coordenadas de mundo y una ruta de cámara ([`camara::Camara`]) que vuela
9//!   entre ellos con zoom/pan/giro. El modo lineal es su caso degenerado.
10//!
11//! Todo agnóstico: sin DOM, sin wasm-bindgen, sin render.
12
13pub mod camara;
14pub mod recorrido;
15/// Adaptador opcional pluma → Recorrido. Sólo con la feature `pluma`.
16#[cfg(feature = "pluma")]
17pub mod adaptador;
18
19pub use camara::{Camara, Ease, Rect, FIT_MARGEN, ZOOM_MAX, ZOOM_MIN};
20pub use recorrido::{
21    Autoplay, ContenidoMarco, Marco, MarcoId, Recorrido, RecorridoState, RejillaOpts,
22    DURACION_PASO_S, DWELL_S,
23};
24
25/// Umbral en pixels para confirmar gesto horizontal vs vertical.
26pub const DRAG_DECISION_PX: f64 = 8.0;
27/// Cuán más horizontal que vertical debe ser el delta para considerarse "swipe".
28pub const HORIZONTAL_BIAS: f64 = 1.3;
29
30#[derive(Clone, Debug, Default)]
31pub struct DeckState {
32    pub current_index: usize,
33    pointer_start: Option<(f64, f64, i32)>,
34    drag_active: bool,
35    drag_start_offset: f64,
36}
37
38#[derive(Clone, Debug, PartialEq)]
39pub enum DragOutcome {
40    /// Aún no hay decisión — esperar más movimiento.
41    Idle,
42    /// Empezar drag horizontal: el host debe capturar el pointer.
43    StartHorizontal { pointer_id: i32 },
44    /// Movimiento vertical predominante — host debe ceder al scroll nativo.
45    CancelVertical,
46    /// Drag en curso — host debe trasladar el strip a este offset.
47    DragOffset(f64),
48}
49
50#[derive(Clone, Debug, PartialEq)]
51pub struct SnapResult {
52    pub target_index: usize,
53    pub offset_px: f64,
54    pub changed: bool,
55}
56
57impl DeckState {
58    pub fn new() -> Self { Self::default() }
59
60    /// Marca el inicio de un gesto. `viewport_width` se usa para anclar el
61    /// drag_start_offset a la página visible actual.
62    pub fn pointer_down(&mut self, x: f64, y: f64, pointer_id: i32, viewport_width: f64) {
63        self.pointer_start = Some((x, y, pointer_id));
64        self.drag_active = false;
65        self.drag_start_offset = -(self.current_index as f64) * viewport_width;
66    }
67
68    /// Procesa un movimiento. Devuelve la acción que el host debe ejecutar.
69    pub fn pointer_move(&mut self, x: f64, y: f64) -> DragOutcome {
70        let Some((sx, sy, pid)) = self.pointer_start else {
71            return DragOutcome::Idle;
72        };
73        let dx = x - sx;
74        let dy = y - sy;
75        if !self.drag_active {
76            let abs_dx = dx.abs();
77            let abs_dy = dy.abs();
78            if abs_dx > DRAG_DECISION_PX && abs_dx > abs_dy * HORIZONTAL_BIAS {
79                self.drag_active = true;
80                return DragOutcome::StartHorizontal { pointer_id: pid };
81            } else if abs_dy > DRAG_DECISION_PX {
82                self.pointer_start = None;
83                return DragOutcome::CancelVertical;
84            } else {
85                return DragOutcome::Idle;
86            }
87        }
88        DragOutcome::DragOffset(self.drag_start_offset + dx)
89    }
90
91    /// Finaliza el gesto. Si había drag activo, calcula la página snap y
92    /// actualiza `current_index`. `current_offset` viene del estado real
93    /// del strip (el host lee CSS transform / variable).
94    pub fn pointer_end(
95        &mut self,
96        current_offset: f64,
97        viewport_width: f64,
98        n_pages: usize,
99    ) -> Option<SnapResult> {
100        let was_active = self.drag_active;
101        self.drag_active = false;
102        self.pointer_start = None;
103        if !was_active || viewport_width <= 0.0 || n_pages == 0 {
104            return None;
105        }
106        let raw = -current_offset / viewport_width;
107        let target = (raw.round().max(0.0) as usize).min(n_pages - 1);
108        let offset_px = -(target as f64) * viewport_width;
109        let changed = self.current_index != target;
110        self.current_index = target;
111        Some(SnapResult { target_index: target, offset_px, changed })
112    }
113
114    /// Salto programático (click en tabs externos). Devuelve el offset
115    /// resultante para que el host lo aplique al strip.
116    pub fn goto(&mut self, index: usize, viewport_width: f64) -> SnapResult {
117        let offset_px = -(index as f64) * viewport_width;
118        let changed = self.current_index != index;
119        self.current_index = index;
120        SnapResult { target_index: index, offset_px, changed }
121    }
122
123    /// Reposiciona tras un resize. Devuelve el offset que el host debe
124    /// aplicar sin animación.
125    pub fn reposition(&self, viewport_width: f64) -> f64 {
126        -(self.current_index as f64) * viewport_width
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn vertical_drag_is_cancelled() {
136        let mut s = DeckState::new();
137        s.pointer_down(100.0, 100.0, 1, 800.0);
138        // Movimiento vertical mayor que el umbral.
139        let r = s.pointer_move(100.0, 120.0);
140        assert_eq!(r, DragOutcome::CancelVertical);
141    }
142
143    #[test]
144    fn horizontal_drag_starts_after_threshold() {
145        let mut s = DeckState::new();
146        s.pointer_down(100.0, 100.0, 7, 800.0);
147        // Justo por debajo del umbral → Idle.
148        assert_eq!(s.pointer_move(105.0, 100.0), DragOutcome::Idle);
149        // Sobre el umbral con bias horizontal → Start.
150        let r = s.pointer_move(120.0, 100.0);
151        assert_eq!(r, DragOutcome::StartHorizontal { pointer_id: 7 });
152    }
153
154    #[test]
155    fn snap_rounds_to_nearest_page() {
156        let mut s = DeckState::new();
157        s.current_index = 1;
158        s.pointer_down(0.0, 0.0, 1, 1000.0); // drag_start_offset = -1000
159        // Forzar drag activo
160        s.pointer_move(20.0, 0.0);
161        // Offset actual = -1000 + 20 = -980 → target round(980/1000) = 1, sin cambio
162        let r = s.pointer_end(-980.0, 1000.0, 3).unwrap();
163        assert_eq!(r.target_index, 1);
164        assert!(!r.changed);
165        // Mover lo suficiente para snapear a página 0
166        s.pointer_down(0.0, 0.0, 1, 1000.0);
167        s.pointer_move(600.0, 0.0);
168        let r = s.pointer_end(-400.0, 1000.0, 3).unwrap();
169        assert_eq!(r.target_index, 0);
170        assert!(r.changed);
171    }
172
173    #[test]
174    fn snap_clamps_to_bounds() {
175        let mut s = DeckState::new();
176        s.current_index = 2;
177        s.pointer_down(0.0, 0.0, 1, 500.0);
178        s.pointer_move(50.0, 0.0);
179        // Offset muy a la izquierda → debería clamp a n_pages-1
180        let r = s.pointer_end(-9999.0, 500.0, 3).unwrap();
181        assert_eq!(r.target_index, 2);
182    }
183
184    #[test]
185    fn goto_updates_index_and_offset() {
186        let mut s = DeckState::new();
187        let r = s.goto(2, 800.0);
188        assert_eq!(r.target_index, 2);
189        assert_eq!(r.offset_px, -1600.0);
190        assert!(r.changed);
191        // segundo goto al mismo índice → changed=false
192        let r = s.goto(2, 800.0);
193        assert!(!r.changed);
194    }
195}