gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ 38d3dddaeabd5148cb21e7575830cecdd1937d66 widgets/carousel/src/lib.rs Rust · 303 siq'ikuna · 9.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · 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
19use std::sync::Arc;
20
21use llimphi_ui::llimphi_layout::taffy::{
22    prelude::{length, percent, FlexDirection, Size, Style},
23    AlignItems, JustifyContent, Position, Rect,
24};
25use llimphi_ui::llimphi_raster::peniko::Color;
26use llimphi_ui::View;
27use llimphi_theme::Theme;
28
29const DOT_SIZE: f32 = 8.0;
30const DOT_GAP: f32 = 8.0;
31const DOT_ROW_H: f32 = 28.0;
32const ARROW_W: f32 = 36.0;
33
34/// Paleta del carousel.
35#[derive(Debug, Clone, Copy)]
36pub struct CarouselPalette {
37    /// Fondo del dot inactivo.
38    pub dot_idle: Color,
39    /// Fondo del dot activo (página actual).
40    pub dot_active: Color,
41    /// Fondo del dot al hover.
42    pub dot_hover: Color,
43    /// Color del glifo de la flecha (‹ / ›).
44    pub arrow_fg: Color,
45    /// Fondo de la flecha al hover.
46    pub arrow_hover_bg: Color,
47}
48
49impl CarouselPalette {
50    pub fn from_theme(t: &Theme) -> Self {
51        Self {
52            dot_idle: t.border,
53            dot_active: t.accent,
54            dot_hover: t.fg_muted,
55            arrow_fg: t.fg_muted,
56            arrow_hover_bg: t.bg_row_hover,
57        }
58    }
59}
60
61impl Default for CarouselPalette {
62    fn default() -> Self {
63        Self::from_theme(&Theme::dark())
64    }
65}
66
67/// Estrategia para los extremos cuando el usuario aprieta `‹` en la
68/// primera página o `›` en la última.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum CarouselWrap {
71    /// La página anterior a la 0 es la última; la posterior a la última
72    /// es la 0. Comportamiento "infinite carousel".
73    Wrap,
74    /// `‹` en la página 0 y `›` en la última no hacen nada (el callback
75    /// igual recibe `i = current`, idempotente).
76    Clamp,
77}
78
79impl Default for CarouselWrap {
80    fn default() -> Self {
81        Self::Clamp
82    }
83}
84
85/// Índice resultante de moverse `delta` páginas desde `current` con
86/// wrap. `total = 0` devuelve `0` (no hay páginas).
87pub fn wrap_index(current: usize, total: usize, delta: i32) -> usize {
88    if total == 0 {
89        return 0;
90    }
91    let total_i = total as i32;
92    let raw = current as i32 + delta;
93    raw.rem_euclid(total_i) as usize
94}
95
96/// Índice resultante de moverse `delta` páginas desde `current` con
97/// clamp. `total = 0` devuelve `0`.
98pub fn clamp_index(current: usize, total: usize, delta: i32) -> usize {
99    if total == 0 {
100        return 0;
101    }
102    let raw = current as i32 + delta;
103    raw.clamp(0, total as i32 - 1) as usize
104}
105
106/// Avanza/retrocede según la estrategia.
107pub fn navigate(current: usize, total: usize, delta: i32, wrap: CarouselWrap) -> usize {
108    match wrap {
109        CarouselWrap::Wrap => wrap_index(current, total, delta),
110        CarouselWrap::Clamp => clamp_index(current, total, delta),
111    }
112}
113
114/// Especificación del carousel.
115pub struct CarouselSpec<Msg> {
116    /// Páginas a mostrar. Se renderiza sólo la página `current`.
117    pub pages: Vec<View<Msg>>,
118    /// Índice de la página visible. Se acota a `pages.len() - 1`.
119    pub current: usize,
120    /// Estrategia para los extremos.
121    pub wrap: CarouselWrap,
122    /// Si `true`, muestra flechas `‹` / `›` superpuestas a los lados.
123    pub show_arrows: bool,
124    pub palette: CarouselPalette,
125    /// Disparado al hacer click en un dot o una flecha — recibe el nuevo
126    /// índice (`0..pages.len()`).
127    pub on_change: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
128}
129
130/// Vista del carousel. Devuelve un `View<Msg>` con el slot que el
131/// caller le asigne (página + dots abajo + flechas opcionales). Si
132/// `pages` está vacía devuelve un `View` vacío del mismo slot.
133pub fn carousel_view<Msg>(spec: CarouselSpec<Msg>) -> View<Msg>
134where
135    Msg: Clone + 'static,
136{
137    let CarouselSpec {
138        mut pages,
139        current,
140        wrap,
141        show_arrows,
142        palette,
143        on_change,
144    } = spec;
145
146    let total = pages.len();
147    if total == 0 {
148        return View::<Msg>::new(Style {
149            size: Size { width: percent(1.0), height: percent(1.0) },
150            ..Default::default()
151        });
152    }
153    let cur = current.min(total - 1);
154
155    // Página visible — drenamos el vector para no clonar.
156    let page = std::mem::replace(
157        &mut pages[cur],
158        View::<Msg>::new(Style::default()),
159    );
160
161    // Wrapper de la página: 100% × resto (todo menos la barra de dots).
162    let page_layer = View::<Msg>::new(Style {
163        position: Position::Relative,
164        size: Size { width: percent(1.0), height: percent(1.0) },
165        ..Default::default()
166    })
167    .children(vec![page]);
168
169    // Flechas opcionales superpuestas a los lados.
170    let mut page_children: Vec<View<Msg>> = vec![page_layer];
171    if show_arrows && total > 1 {
172        let on_prev = on_change.clone();
173        let prev_idx = navigate(cur, total, -1, wrap);
174        let prev = View::<Msg>::new(Style {
175            position: Position::Absolute,
176            inset: Rect {
177                left: length(0.0),
178                top: length(0.0),
179                bottom: length(0.0),
180                right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
181            },
182            size: Size {
183                width: length(ARROW_W),
184                height: percent(1.0),
185            },
186            align_items: Some(AlignItems::Center),
187            justify_content: Some(JustifyContent::Center),
188            ..Default::default()
189        })
190        .text("‹", 22.0, palette.arrow_fg)
191        .hover_fill(palette.arrow_hover_bg)
192        .cursor(llimphi_ui::Cursor::Pointer)
193        .on_click_at(move |_, _, _, _| Some((on_prev)(prev_idx)));
194
195        let on_next = on_change.clone();
196        let next_idx = navigate(cur, total, 1, wrap);
197        let next = View::<Msg>::new(Style {
198            position: Position::Absolute,
199            inset: Rect {
200                right: length(0.0),
201                top: length(0.0),
202                bottom: length(0.0),
203                left: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
204            },
205            size: Size {
206                width: length(ARROW_W),
207                height: percent(1.0),
208            },
209            align_items: Some(AlignItems::Center),
210            justify_content: Some(JustifyContent::Center),
211            ..Default::default()
212        })
213        .text("›", 22.0, palette.arrow_fg)
214        .hover_fill(palette.arrow_hover_bg)
215        .cursor(llimphi_ui::Cursor::Pointer)
216        .on_click_at(move |_, _, _, _| Some((on_next)(next_idx)));
217
218        page_children.push(prev);
219        page_children.push(next);
220    }
221    let page_area = View::<Msg>::new(Style {
222        position: Position::Relative,
223        size: Size { width: percent(1.0), height: percent(1.0) },
224        flex_grow: 1.0,
225        ..Default::default()
226    })
227    .children(page_children);
228
229    // Fila de dots abajo.
230    let mut dots: Vec<View<Msg>> = Vec::with_capacity(total);
231    for i in 0..total {
232        let f = on_change.clone();
233        let is_active = i == cur;
234        let mut dot = View::<Msg>::new(Style {
235            size: Size { width: length(DOT_SIZE), height: length(DOT_SIZE) },
236            ..Default::default()
237        })
238        .radius((DOT_SIZE * 0.5) as f64)
239        .cursor(llimphi_ui::Cursor::Pointer);
240        if is_active {
241            dot = dot.fill(palette.dot_active);
242        } else {
243            dot = dot.fill(palette.dot_idle).hover_fill(palette.dot_hover);
244        }
245        dot = dot.on_click_at(move |_, _, _, _| Some((f)(i)));
246        dots.push(dot);
247    }
248    let dot_row = View::<Msg>::new(Style {
249        flex_direction: FlexDirection::Row,
250        align_items: Some(AlignItems::Center),
251        justify_content: Some(JustifyContent::Center),
252        gap: llimphi_ui::llimphi_layout::taffy::Size {
253            width: length(DOT_GAP),
254            height: length(0.0),
255        },
256        size: Size { width: percent(1.0), height: length(DOT_ROW_H) },
257        ..Default::default()
258    })
259    .children(dots);
260
261    View::<Msg>::new(Style {
262        flex_direction: FlexDirection::Column,
263        size: Size { width: percent(1.0), height: percent(1.0) },
264        ..Default::default()
265    })
266    .children(vec![page_area, dot_row])
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn wrap_index_envuelve_en_los_extremos() {
275        assert_eq!(wrap_index(0, 5, -1), 4);
276        assert_eq!(wrap_index(4, 5, 1), 0);
277        assert_eq!(wrap_index(2, 5, 0), 2);
278        // Total grande: no overflow.
279        assert_eq!(wrap_index(2, 5, 12), 4); // 2+12=14, %5=4
280        assert_eq!(wrap_index(0, 5, -7), 3); // -7 rem_euclid 5 = 3
281        // Total 0: defensa.
282        assert_eq!(wrap_index(0, 0, 1), 0);
283    }
284
285    #[test]
286    fn clamp_index_se_queda_en_los_extremos() {
287        assert_eq!(clamp_index(0, 5, -1), 0);
288        assert_eq!(clamp_index(4, 5, 1), 4);
289        assert_eq!(clamp_index(2, 5, 1), 3);
290        assert_eq!(clamp_index(2, 5, 99), 4);
291        assert_eq!(clamp_index(2, 5, -99), 0);
292        // Total 0: defensa.
293        assert_eq!(clamp_index(0, 0, 1), 0);
294    }
295
296    #[test]
297    fn navigate_aplica_estrategia() {
298        assert_eq!(navigate(0, 5, -1, CarouselWrap::Wrap), 4);
299        assert_eq!(navigate(0, 5, -1, CarouselWrap::Clamp), 0);
300        assert_eq!(navigate(4, 5, 1, CarouselWrap::Wrap), 0);
301        assert_eq!(navigate(4, 5, 1, CarouselWrap::Clamp), 4);
302    }
303}