refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la estructura vieja de eventloop) y suma el 3D: - bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 / accesskit_winit 0.33 / vello_hybrid 0.0.9. - nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido, montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox). - README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif). - excluido modules/allichay (arrastra deps fuera del alcance del front-door). - cargo check --workspace: verde. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "llimphi-widget-carousel"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-widget-carousel — pager paginado de N páginas con dots indicadores y flechas opcionales a los lados. El caller maneja solo `current_index`; cada cambio dispara `on_change(i)`. Útil para onboarding, galerías, slideshows."
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
@@ -0,0 +1,303 @@
|
||||
//! `llimphi-widget-carousel` — pager paginado.
|
||||
//!
|
||||
//! Una vista que muestra N páginas, una a la vez, con **dots indicadores**
|
||||
//! abajo (clickeables para saltar a la página i) y **flechas opcionales**
|
||||
//! a los costados. El caller mantiene un único `current_index: usize` en
|
||||
//! su modelo y recibe `on_change(i)` cuando el usuario cambia de página.
|
||||
//!
|
||||
//! v1 **sin swipe** — la navegación va por dots y por flechas. Una v2
|
||||
//! puede agregar swipe horizontal usando `View::draggable_velocity` +
|
||||
//! `fling_step` para snap-on-release (el seam ya existe; sumarlo es
|
||||
//! composición).
|
||||
//!
|
||||
//! Helpers puros para wrap/clamp del índice ([`wrap_index`],
|
||||
//! [`clamp_index`]) — útiles para la lógica del `update` del caller (las
|
||||
//! flechas en los extremos pueden envolver o quedarse).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, percent, FlexDirection, Size, Style},
|
||||
AlignItems, JustifyContent, Position, Rect,
|
||||
};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::View;
|
||||
use llimphi_theme::Theme;
|
||||
|
||||
const DOT_SIZE: f32 = 8.0;
|
||||
const DOT_GAP: f32 = 8.0;
|
||||
const DOT_ROW_H: f32 = 28.0;
|
||||
const ARROW_W: f32 = 36.0;
|
||||
|
||||
/// Paleta del carousel.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CarouselPalette {
|
||||
/// Fondo del dot inactivo.
|
||||
pub dot_idle: Color,
|
||||
/// Fondo del dot activo (página actual).
|
||||
pub dot_active: Color,
|
||||
/// Fondo del dot al hover.
|
||||
pub dot_hover: Color,
|
||||
/// Color del glifo de la flecha (‹ / ›).
|
||||
pub arrow_fg: Color,
|
||||
/// Fondo de la flecha al hover.
|
||||
pub arrow_hover_bg: Color,
|
||||
}
|
||||
|
||||
impl CarouselPalette {
|
||||
pub fn from_theme(t: &Theme) -> Self {
|
||||
Self {
|
||||
dot_idle: t.border,
|
||||
dot_active: t.accent,
|
||||
dot_hover: t.fg_muted,
|
||||
arrow_fg: t.fg_muted,
|
||||
arrow_hover_bg: t.bg_row_hover,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CarouselPalette {
|
||||
fn default() -> Self {
|
||||
Self::from_theme(&Theme::dark())
|
||||
}
|
||||
}
|
||||
|
||||
/// Estrategia para los extremos cuando el usuario aprieta `‹` en la
|
||||
/// primera página o `›` en la última.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CarouselWrap {
|
||||
/// La página anterior a la 0 es la última; la posterior a la última
|
||||
/// es la 0. Comportamiento "infinite carousel".
|
||||
Wrap,
|
||||
/// `‹` en la página 0 y `›` en la última no hacen nada (el callback
|
||||
/// igual recibe `i = current`, idempotente).
|
||||
Clamp,
|
||||
}
|
||||
|
||||
impl Default for CarouselWrap {
|
||||
fn default() -> Self {
|
||||
Self::Clamp
|
||||
}
|
||||
}
|
||||
|
||||
/// Índice resultante de moverse `delta` páginas desde `current` con
|
||||
/// wrap. `total = 0` devuelve `0` (no hay páginas).
|
||||
pub fn wrap_index(current: usize, total: usize, delta: i32) -> usize {
|
||||
if total == 0 {
|
||||
return 0;
|
||||
}
|
||||
let total_i = total as i32;
|
||||
let raw = current as i32 + delta;
|
||||
raw.rem_euclid(total_i) as usize
|
||||
}
|
||||
|
||||
/// Índice resultante de moverse `delta` páginas desde `current` con
|
||||
/// clamp. `total = 0` devuelve `0`.
|
||||
pub fn clamp_index(current: usize, total: usize, delta: i32) -> usize {
|
||||
if total == 0 {
|
||||
return 0;
|
||||
}
|
||||
let raw = current as i32 + delta;
|
||||
raw.clamp(0, total as i32 - 1) as usize
|
||||
}
|
||||
|
||||
/// Avanza/retrocede según la estrategia.
|
||||
pub fn navigate(current: usize, total: usize, delta: i32, wrap: CarouselWrap) -> usize {
|
||||
match wrap {
|
||||
CarouselWrap::Wrap => wrap_index(current, total, delta),
|
||||
CarouselWrap::Clamp => clamp_index(current, total, delta),
|
||||
}
|
||||
}
|
||||
|
||||
/// Especificación del carousel.
|
||||
pub struct CarouselSpec<Msg> {
|
||||
/// Páginas a mostrar. Se renderiza sólo la página `current`.
|
||||
pub pages: Vec<View<Msg>>,
|
||||
/// Índice de la página visible. Se acota a `pages.len() - 1`.
|
||||
pub current: usize,
|
||||
/// Estrategia para los extremos.
|
||||
pub wrap: CarouselWrap,
|
||||
/// Si `true`, muestra flechas `‹` / `›` superpuestas a los lados.
|
||||
pub show_arrows: bool,
|
||||
pub palette: CarouselPalette,
|
||||
/// Disparado al hacer click en un dot o una flecha — recibe el nuevo
|
||||
/// índice (`0..pages.len()`).
|
||||
pub on_change: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Vista del carousel. Devuelve un `View<Msg>` con el slot que el
|
||||
/// caller le asigne (página + dots abajo + flechas opcionales). Si
|
||||
/// `pages` está vacía devuelve un `View` vacío del mismo slot.
|
||||
pub fn carousel_view<Msg>(spec: CarouselSpec<Msg>) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + 'static,
|
||||
{
|
||||
let CarouselSpec {
|
||||
mut pages,
|
||||
current,
|
||||
wrap,
|
||||
show_arrows,
|
||||
palette,
|
||||
on_change,
|
||||
} = spec;
|
||||
|
||||
let total = pages.len();
|
||||
if total == 0 {
|
||||
return View::<Msg>::new(Style {
|
||||
size: Size { width: percent(1.0), height: percent(1.0) },
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let cur = current.min(total - 1);
|
||||
|
||||
// Página visible — drenamos el vector para no clonar.
|
||||
let page = std::mem::replace(
|
||||
&mut pages[cur],
|
||||
View::<Msg>::new(Style::default()),
|
||||
);
|
||||
|
||||
// Wrapper de la página: 100% × resto (todo menos la barra de dots).
|
||||
let page_layer = View::<Msg>::new(Style {
|
||||
position: Position::Relative,
|
||||
size: Size { width: percent(1.0), height: percent(1.0) },
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![page]);
|
||||
|
||||
// Flechas opcionales superpuestas a los lados.
|
||||
let mut page_children: Vec<View<Msg>> = vec![page_layer];
|
||||
if show_arrows && total > 1 {
|
||||
let on_prev = on_change.clone();
|
||||
let prev_idx = navigate(cur, total, -1, wrap);
|
||||
let prev = View::<Msg>::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(0.0),
|
||||
top: length(0.0),
|
||||
bottom: length(0.0),
|
||||
right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: length(ARROW_W),
|
||||
height: percent(1.0),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text("‹", 22.0, palette.arrow_fg)
|
||||
.hover_fill(palette.arrow_hover_bg)
|
||||
.cursor(llimphi_ui::Cursor::Pointer)
|
||||
.on_click_at(move |_, _, _, _| Some((on_prev)(prev_idx)));
|
||||
|
||||
let on_next = on_change.clone();
|
||||
let next_idx = navigate(cur, total, 1, wrap);
|
||||
let next = View::<Msg>::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
right: length(0.0),
|
||||
top: length(0.0),
|
||||
bottom: length(0.0),
|
||||
left: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: length(ARROW_W),
|
||||
height: percent(1.0),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text("›", 22.0, palette.arrow_fg)
|
||||
.hover_fill(palette.arrow_hover_bg)
|
||||
.cursor(llimphi_ui::Cursor::Pointer)
|
||||
.on_click_at(move |_, _, _, _| Some((on_next)(next_idx)));
|
||||
|
||||
page_children.push(prev);
|
||||
page_children.push(next);
|
||||
}
|
||||
let page_area = View::<Msg>::new(Style {
|
||||
position: Position::Relative,
|
||||
size: Size { width: percent(1.0), height: percent(1.0) },
|
||||
flex_grow: 1.0,
|
||||
..Default::default()
|
||||
})
|
||||
.children(page_children);
|
||||
|
||||
// Fila de dots abajo.
|
||||
let mut dots: Vec<View<Msg>> = Vec::with_capacity(total);
|
||||
for i in 0..total {
|
||||
let f = on_change.clone();
|
||||
let is_active = i == cur;
|
||||
let mut dot = View::<Msg>::new(Style {
|
||||
size: Size { width: length(DOT_SIZE), height: length(DOT_SIZE) },
|
||||
..Default::default()
|
||||
})
|
||||
.radius((DOT_SIZE * 0.5) as f64)
|
||||
.cursor(llimphi_ui::Cursor::Pointer);
|
||||
if is_active {
|
||||
dot = dot.fill(palette.dot_active);
|
||||
} else {
|
||||
dot = dot.fill(palette.dot_idle).hover_fill(palette.dot_hover);
|
||||
}
|
||||
dot = dot.on_click_at(move |_, _, _, _| Some((f)(i)));
|
||||
dots.push(dot);
|
||||
}
|
||||
let dot_row = View::<Msg>::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
gap: llimphi_ui::llimphi_layout::taffy::Size {
|
||||
width: length(DOT_GAP),
|
||||
height: length(0.0),
|
||||
},
|
||||
size: Size { width: percent(1.0), height: length(DOT_ROW_H) },
|
||||
..Default::default()
|
||||
})
|
||||
.children(dots);
|
||||
|
||||
View::<Msg>::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size { width: percent(1.0), height: percent(1.0) },
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![page_area, dot_row])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrap_index_envuelve_en_los_extremos() {
|
||||
assert_eq!(wrap_index(0, 5, -1), 4);
|
||||
assert_eq!(wrap_index(4, 5, 1), 0);
|
||||
assert_eq!(wrap_index(2, 5, 0), 2);
|
||||
// Total grande: no overflow.
|
||||
assert_eq!(wrap_index(2, 5, 12), 4); // 2+12=14, %5=4
|
||||
assert_eq!(wrap_index(0, 5, -7), 3); // -7 rem_euclid 5 = 3
|
||||
// Total 0: defensa.
|
||||
assert_eq!(wrap_index(0, 0, 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_index_se_queda_en_los_extremos() {
|
||||
assert_eq!(clamp_index(0, 5, -1), 0);
|
||||
assert_eq!(clamp_index(4, 5, 1), 4);
|
||||
assert_eq!(clamp_index(2, 5, 1), 3);
|
||||
assert_eq!(clamp_index(2, 5, 99), 4);
|
||||
assert_eq!(clamp_index(2, 5, -99), 0);
|
||||
// Total 0: defensa.
|
||||
assert_eq!(clamp_index(0, 0, 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigate_aplica_estrategia() {
|
||||
assert_eq!(navigate(0, 5, -1, CarouselWrap::Wrap), 4);
|
||||
assert_eq!(navigate(0, 5, -1, CarouselWrap::Clamp), 0);
|
||||
assert_eq!(navigate(4, 5, 1, CarouselWrap::Wrap), 0);
|
||||
assert_eq!(navigate(4, 5, 1, CarouselWrap::Clamp), 4);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user