tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 38d3dddaeabd5148cb21e7575830cecdd1937d66
widgets/carousel/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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}