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:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+12
View File
@@ -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 }
+303
View File
@@ -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);
}
}