tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}