tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 238TOML 102Markdown 83Shell 24178 Definitions

⎇ main widgets/carousel/src/lib.rs Rust · 470 lines · 17.1 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! `llimphi-widget-carousel` — pager paginado.
2//!
3//! Una vista que muestra N páginas, una a la vez, con **dots indicadores**
4//! abajo (clickeables para saltar a la página i) y **flechas opcionales**
5//! a los costados. El caller mantiene un único `current_index: usize` en
6//! su modelo y recibe `on_change(i)` cuando el usuario cambia de página.
7//!
8//! v1 **sin swipe** — la navegación va por dots y por flechas. Una v2
9//! puede agregar swipe horizontal usando `View::draggable_velocity` +
10//! `fling_step` para snap-on-release (el seam ya existe; sumarlo es
11//! composición).
12//!
13//! Helpers puros para wrap/clamp del índice ([`wrap_index`],
14//! [`clamp_index`]) — útiles para la lógica del `update` del caller (las
15//! flechas en los extremos pueden envolver o quedarse).
16
17#![forbid(unsafe_code)]
18
19
20// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
21// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
22// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
23#![no_std]
24
25extern crate alloc;
26
27use alloc::vec::Vec;
28use alloc::vec;
29use alloc::sync::Arc;
30
31use llimphi_compositor::llimphi_layout::taffy::{
32    prelude::{length, percent, FlexDirection, Size, Style},
33    AlignItems, JustifyContent, Position, Rect,
34};
35use llimphi_compositor::peniko::Color;
36use llimphi_compositor::View;
37use llimphi_theme::Theme;
38
39const DOT_SIZE: f32 = 8.0;
40const DOT_GAP: f32 = 8.0;
41const DOT_ROW_H: f32 = 28.0;
42const ARROW_W: f32 = 36.0;
43
44/// Paleta del carousel.
45#[derive(Debug, Clone, Copy)]
46pub struct CarouselPalette {
47    /// Fondo del dot inactivo.
48    pub dot_idle: Color,
49    /// Fondo del dot activo (página actual).
50    pub dot_active: Color,
51    /// Fondo del dot al hover.
52    pub dot_hover: Color,
53    /// Color del glifo de la flecha (‹ / ›).
54    pub arrow_fg: Color,
55    /// Fondo de la flecha al hover.
56    pub arrow_hover_bg: Color,
57}
58
59impl CarouselPalette {
60    pub fn from_theme(t: &Theme) -> Self {
61        Self {
62            dot_idle: t.border,
63            dot_active: t.accent,
64            dot_hover: t.fg_muted,
65            arrow_fg: t.fg_muted,
66            arrow_hover_bg: t.bg_row_hover,
67        }
68    }
69}
70
71impl Default for CarouselPalette {
72    fn default() -> Self {
73        Self::from_theme(&Theme::dark())
74    }
75}
76
77/// Estrategia para los extremos cuando el usuario aprieta `‹` en la
78/// primera página o `›` en la última.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum CarouselWrap {
81    /// La página anterior a la 0 es la última; la posterior a la última
82    /// es la 0. Comportamiento "infinite carousel".
83    Wrap,
84    /// `‹` en la página 0 y `›` en la última no hacen nada (el callback
85    /// igual recibe `i = current`, idempotente).
86    Clamp,
87}
88
89impl Default for CarouselWrap {
90    fn default() -> Self {
91        Self::Clamp
92    }
93}
94
95/// Índice resultante de moverse `delta` páginas desde `current` con
96/// wrap. `total = 0` devuelve `0` (no hay páginas).
97pub fn wrap_index(current: usize, total: usize, delta: i32) -> usize {
98    if total == 0 {
99        return 0;
100    }
101    let total_i = total as i32;
102    let raw = current as i32 + delta;
103    raw.rem_euclid(total_i) as usize
104}
105
106/// Índice resultante de moverse `delta` páginas desde `current` con
107/// clamp. `total = 0` devuelve `0`.
108pub fn clamp_index(current: usize, total: usize, delta: i32) -> usize {
109    if total == 0 {
110        return 0;
111    }
112    let raw = current as i32 + delta;
113    raw.clamp(0, total as i32 - 1) as usize
114}
115
116/// Avanza/retrocede según la estrategia.
117pub fn navigate(current: usize, total: usize, delta: i32, wrap: CarouselWrap) -> usize {
118    match wrap {
119        CarouselWrap::Wrap => wrap_index(current, total, delta),
120        CarouselWrap::Clamp => clamp_index(current, total, delta),
121    }
122}
123
124/// Umbral de distancia (px) para que un swipe cambie de página.
125pub const SWIPE_DIST: f32 = 50.0;
126/// Umbral de velocidad (px/s) para un "fling" que cambia de página aunque
127/// el desplazamiento sea corto.
128pub const SWIPE_FLING: f32 = 450.0;
129
130/// Decide el cambio de página de un swipe horizontal a partir del
131/// desplazamiento total (`total_dx`, px, acumulado desde el press) y la
132/// velocidad al soltar (`vx`, px/s). Convención natural: arrastrar **hacia la
133/// izquierda** (dx<0) avanza a la **página siguiente** (`+1`); hacia la derecha
134/// (`-1`). Devuelve `0` si no llega a ningún umbral. Pura y testeable.
135pub fn swipe_decision(total_dx: f32, vx: f32) -> i32 {
136    // Fling rápido gana aunque el arrastre haya sido corto.
137    if vx <= -SWIPE_FLING {
138        return 1;
139    }
140    if vx >= SWIPE_FLING {
141        return -1;
142    }
143    if total_dx <= -SWIPE_DIST {
144        return 1;
145    }
146    if total_dx >= SWIPE_DIST {
147        return -1;
148    }
149    0
150}
151
152/// Transición animada entre páginas: mientras `t` va de 0→1, la página `from`
153/// se desliza hacia afuera y la actual entra desde el lado opuesto. El host lo
154/// anima con `llimphi-motion` y lo pone en `None` al terminar.
155#[derive(Debug, Clone, Copy)]
156pub struct CarouselTransition {
157    /// Página que sale (la anterior).
158    pub from: usize,
159    /// Progreso 0→1.
160    pub t: f32,
161}
162
163/// Desplazamientos horizontales (fracción del ancho) de las páginas saliente y
164/// entrante durante una transición de progreso `t`. `forward = true` (voy a una
165/// página mayor) → la entrante viene desde la derecha. Pura y testeable.
166pub fn slide_offsets(t: f32, forward: bool) -> (f32, f32) {
167    let t = t.clamp(0.0, 1.0);
168    if forward {
169        (-t, 1.0 - t) // saliente ← ; entrante desde la derecha
170    } else {
171        (t, t - 1.0) // saliente → ; entrante desde la izquierda
172    }
173}
174
175/// Especificación del carousel.
176pub struct CarouselSpec<Msg> {
177    /// Páginas a mostrar. Se renderiza sólo la página `current`.
178    pub pages: Vec<View<Msg>>,
179    /// Índice de la página visible. Se acota a `pages.len() - 1`.
180    pub current: usize,
181    /// Estrategia para los extremos.
182    pub wrap: CarouselWrap,
183    /// Si `true`, muestra flechas `‹` / `›` superpuestas a los lados.
184    pub show_arrows: bool,
185    pub palette: CarouselPalette,
186    /// Disparado al hacer click en un dot o una flecha — recibe el nuevo
187    /// índice (`0..pages.len()`).
188    pub on_change: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
189    /// Swipe horizontal: al arrastrar el área de página y soltar, recibe `+1`
190    /// (siguiente) o `-1` (anterior) según [`swipe_decision`]. `None` = sin
191    /// swipe (sólo dots/flechas). El caller resuelve el índice con [`navigate`].
192    pub on_swipe: Option<Arc<dyn Fn(i32) -> Msg + Send + Sync>>,
193    /// Transición animada en curso (deslizamiento entre `from` y `current`).
194    /// `None` = cambio instantáneo (comportamiento previo).
195    pub transition: Option<CarouselTransition>,
196}
197
198/// Vista del carousel. Devuelve un `View<Msg>` con el slot que el
199/// caller le asigne (página + dots abajo + flechas opcionales). Si
200/// `pages` está vacía devuelve un `View` vacío del mismo slot.
201pub fn carousel_view<Msg>(spec: CarouselSpec<Msg>) -> View<Msg>
202where
203    Msg: Clone + 'static,
204{
205    let CarouselSpec {
206        mut pages,
207        current,
208        wrap,
209        show_arrows,
210        palette,
211        on_change,
212        on_swipe,
213        transition,
214    } = spec;
215
216    let total = pages.len();
217    if total == 0 {
218        return View::<Msg>::new(Style {
219            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
220            ..Default::default()
221        });
222    }
223    let cur = current.min(total - 1);
224
225    // Wrapper de página a una fracción horizontal del ancho (para la transición).
226    let slide_wrapper = |page: View<Msg>, frac: f32| -> View<Msg> {
227        View::<Msg>::new(Style {
228            position: Position::Absolute,
229            inset: Rect {
230                left: percent(frac),
231                top: length(0.0_f32),
232                bottom: length(0.0_f32),
233                right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
234            },
235            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
236            ..Default::default()
237        })
238        .children(vec![page])
239    };
240
241    // ¿Transición activa (from ≠ cur, en curso)? Renderiza dos páginas
242    // deslizándose; si no, una sola.
243    let tr_active = transition.filter(|tr| tr.from != cur && tr.from < total && tr.t > 0.0 && tr.t < 1.0);
244    let mut page_children: Vec<View<Msg>> = if let Some(tr) = tr_active {
245        // Dirección robusta ante wrap en los extremos.
246        let forward = if tr.from == total - 1 && cur == 0 {
247            true
248        } else if tr.from == 0 && cur == total - 1 {
249            false
250        } else {
251            cur > tr.from
252        };
253        let (out_frac, in_frac) = slide_offsets(tr.t, forward);
254        let empty = || View::<Msg>::new(Style::default());
255        let out_page = core::mem::replace(&mut pages[tr.from], empty());
256        let in_page = core::mem::replace(&mut pages[cur], empty());
257        vec![slide_wrapper(out_page, out_frac), slide_wrapper(in_page, in_frac)]
258    } else {
259        let page = core::mem::replace(&mut pages[cur], View::<Msg>::new(Style::default()));
260        vec![View::<Msg>::new(Style {
261            position: Position::Relative,
262            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
263            ..Default::default()
264        })
265        .children(vec![page])]
266    };
267
268    // Flechas opcionales superpuestas a los lados.
269    if show_arrows && total > 1 {
270        let on_prev = on_change.clone();
271        let prev_idx = navigate(cur, total, -1, wrap);
272        let prev = View::<Msg>::new(Style {
273            position: Position::Absolute,
274            inset: Rect {
275                left: length(0.0_f32),
276                top: length(0.0_f32),
277                bottom: length(0.0_f32),
278                right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
279            },
280            size: Size {
281                width: length(ARROW_W),
282                height: percent(1.0_f32),
283            },
284            align_items: Some(AlignItems::CENTER),
285            justify_content: Some(JustifyContent::CENTER),
286            ..Default::default()
287        })
288        .text("‹", 22.0, palette.arrow_fg)
289        .hover_fill(palette.arrow_hover_bg)
290        .cursor(llimphi_compositor::Cursor::Pointer)
291        .on_click_at(move |_, _, _, _| Some((on_prev)(prev_idx)));
292
293        let on_next = on_change.clone();
294        let next_idx = navigate(cur, total, 1, wrap);
295        let next = View::<Msg>::new(Style {
296            position: Position::Absolute,
297            inset: Rect {
298                right: length(0.0_f32),
299                top: length(0.0_f32),
300                bottom: length(0.0_f32),
301                left: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
302            },
303            size: Size {
304                width: length(ARROW_W),
305                height: percent(1.0_f32),
306            },
307            align_items: Some(AlignItems::CENTER),
308            justify_content: Some(JustifyContent::CENTER),
309            ..Default::default()
310        })
311        .text("›", 22.0, palette.arrow_fg)
312        .hover_fill(palette.arrow_hover_bg)
313        .cursor(llimphi_compositor::Cursor::Pointer)
314        .on_click_at(move |_, _, _, _| Some((on_next)(next_idx)));
315
316        page_children.push(prev);
317        page_children.push(next);
318    }
319    let mut page_area = View::<Msg>::new(Style {
320        position: Position::Relative,
321        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
322        flex_grow: 1.0,
323        ..Default::default()
324    })
325    .children(page_children);
326
327    // Swipe horizontal: arrastrar el área y soltar cambia de página. Acumulamos
328    // el desplazamiento (los deltas llegan por-evento con la vista congelada) y
329    // en End decidimos con velocidad + distancia. Los hijos interactivos (dots,
330    // flechas, contenido) ganan el hit-test; el drag toma el fondo de la página.
331    if let (Some(on_swipe), true) = (on_swipe, total > 1) {
332        // El acumulador del drag, sin `Mutex`: un `AtomicU32` que guarda los bits
333    // del `f32`. No es un capricho no_std — el handler corre en el hilo de la UI
334    // y jamás compite, así que el mutex sólo pagaba la portabilidad que ahora
335    // paga el átomo. `Relaxed` alcanza por lo mismo: no hay otro hilo que
336    // ordenar.
337    let acc = alloc::sync::Arc::new(core::sync::atomic::AtomicU32::new(0.0_f32.to_bits()));
338        page_area = page_area.draggable_velocity(
339            move |phase: llimphi_compositor::DragPhase, dx: f32, _dy: f32, vx: f32, _vy: f32| {
340                use core::sync::atomic::Ordering::Relaxed;
341                let mut a = f32::from_bits(acc.load(Relaxed));
342                match phase {
343                    llimphi_compositor::DragPhase::Move => {
344                        a += dx;
345                        acc.store(a.to_bits(), Relaxed);
346                        None
347                    }
348                    llimphi_compositor::DragPhase::End => {
349                        let total_dx = a;
350                        acc.store(0.0_f32.to_bits(), Relaxed);
351                        let d = swipe_decision(total_dx, vx);
352                        if d != 0 {
353                            Some((on_swipe)(d))
354                        } else {
355                            None
356                        }
357                    }
358                }
359            },
360        );
361    }
362
363    // Fila de dots abajo.
364    let mut dots: Vec<View<Msg>> = Vec::with_capacity(total);
365    for i in 0..total {
366        let f = on_change.clone();
367        let is_active = i == cur;
368        let mut dot = View::<Msg>::new(Style {
369            size: Size { width: length(DOT_SIZE), height: length(DOT_SIZE) },
370            ..Default::default()
371        })
372        .radius((DOT_SIZE * 0.5) as f64)
373        .cursor(llimphi_compositor::Cursor::Pointer);
374        if is_active {
375            dot = dot.fill(palette.dot_active);
376        } else {
377            dot = dot.fill(palette.dot_idle).hover_fill(palette.dot_hover);
378        }
379        dot = dot.on_click_at(move |_, _, _, _| Some((f)(i)));
380        dots.push(dot);
381    }
382    let dot_row = View::<Msg>::new(Style {
383        flex_direction: FlexDirection::Row,
384        align_items: Some(AlignItems::CENTER),
385        justify_content: Some(JustifyContent::CENTER),
386        gap: llimphi_compositor::llimphi_layout::taffy::Size {
387            width: length(DOT_GAP),
388            height: length(0.0_f32),
389        },
390        size: Size { width: percent(1.0_f32), height: length(DOT_ROW_H) },
391        ..Default::default()
392    })
393    .children(dots);
394
395    View::<Msg>::new(Style {
396        flex_direction: FlexDirection::Column,
397        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
398        ..Default::default()
399    })
400    .children(vec![page_area, dot_row])
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn wrap_index_envuelve_en_los_extremos() {
409        assert_eq!(wrap_index(0, 5, -1), 4);
410        assert_eq!(wrap_index(4, 5, 1), 0);
411        assert_eq!(wrap_index(2, 5, 0), 2);
412        // Total grande: no overflow.
413        assert_eq!(wrap_index(2, 5, 12), 4); // 2+12=14, %5=4
414        assert_eq!(wrap_index(0, 5, -7), 3); // -7 rem_euclid 5 = 3
415        // Total 0: defensa.
416        assert_eq!(wrap_index(0, 0, 1), 0);
417    }
418
419    #[test]
420    fn clamp_index_se_queda_en_los_extremos() {
421        assert_eq!(clamp_index(0, 5, -1), 0);
422        assert_eq!(clamp_index(4, 5, 1), 4);
423        assert_eq!(clamp_index(2, 5, 1), 3);
424        assert_eq!(clamp_index(2, 5, 99), 4);
425        assert_eq!(clamp_index(2, 5, -99), 0);
426        // Total 0: defensa.
427        assert_eq!(clamp_index(0, 0, 1), 0);
428    }
429
430    #[test]
431    fn navigate_aplica_estrategia() {
432        assert_eq!(navigate(0, 5, -1, CarouselWrap::Wrap), 4);
433        assert_eq!(navigate(0, 5, -1, CarouselWrap::Clamp), 0);
434        assert_eq!(navigate(4, 5, 1, CarouselWrap::Wrap), 0);
435        assert_eq!(navigate(4, 5, 1, CarouselWrap::Clamp), 4);
436    }
437
438    #[test]
439    fn swipe_por_distancia() {
440        // Arrastrar a la izquierda lo suficiente → siguiente (+1).
441        assert_eq!(swipe_decision(-SWIPE_DIST - 1.0, 0.0), 1);
442        // A la derecha → anterior (-1).
443        assert_eq!(swipe_decision(SWIPE_DIST + 1.0, 0.0), -1);
444        // Corto y lento → nada.
445        assert_eq!(swipe_decision(-10.0, 0.0), 0);
446        assert_eq!(swipe_decision(10.0, 50.0), 0);
447    }
448
449    #[test]
450    fn swipe_por_fling_gana_aunque_sea_corto() {
451        // Fling rápido a la izquierda con desplazamiento corto → siguiente.
452        assert_eq!(swipe_decision(-5.0, -SWIPE_FLING - 10.0), 1);
453        // Fling a la derecha → anterior.
454        assert_eq!(swipe_decision(5.0, SWIPE_FLING + 10.0), -1);
455    }
456
457    #[test]
458    fn slide_offsets_desliza_entrante_y_saliente() {
459        // Forward: al inicio (t=0) la saliente está centrada (0) y la entrante
460        // fuera a la derecha (+1); al final (t=1) la saliente salió (-1) y la
461        // entrante quedó centrada (0).
462        assert_eq!(slide_offsets(0.0, true), (0.0, 1.0));
463        assert_eq!(slide_offsets(1.0, true), (-1.0, 0.0));
464        let (o, i) = slide_offsets(0.5, true);
465        assert!((o + 0.5).abs() < 1e-4 && (i - 0.5).abs() < 1e-4);
466        // Backward: la entrante viene desde la izquierda (-1 → 0).
467        assert_eq!(slide_offsets(0.0, false), (0.0, -1.0));
468        assert_eq!(slide_offsets(1.0, false), (1.0, 0.0));
469    }
470}