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
+437
View File
@@ -0,0 +1,437 @@
//! `select_demo` — recorre el gradiente de complejidad del select en una
//! sola ventana. Tres controles, de tonto a listo:
//!
//! 1. **Simple** — estado de una tarea, sin búsqueda.
//! 2. **Buscable + badges** — asignar a una persona: icono, sublabel y
//! badge de conteo; teclear filtra, ↑/↓ navega, Enter elige.
//! 3. **Async** — el primer load *falla* (mirá el error + Reintentar); el
//! reintento trae los datos tras ~900 ms vía `Handle::spawn`, con guard
//! de generación para descartar respuestas viejas.
//!
//! Corré con:
//! cargo run -p llimphi-widget-select --example select_demo --release
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_select::{
filter, resolve, select_menu_view, select_trigger_view, step_active, BadgeKind, SelectBadge,
SelectItem, SelectMenuSpec, SelectPalette, SelectPhase,
};
const X: f32 = 48.0;
const Y0: f32 = 96.0;
const ROW: f32 = 96.0;
const W: f32 = 340.0;
const TRIGGER_H: f32 = 36.0;
/// Cuál de los tres selects está abierto.
const SEL_ESTADO: usize = 0;
const SEL_PERSONA: usize = 1;
const SEL_ASYNC: usize = 2;
#[derive(Clone)]
enum Msg {
Toggle(usize),
Dismiss,
Pick(usize, usize), // (cuál select, índice original)
Hover(usize), // posición en visible del select abierto
Key(KeyEvent),
Retry,
// Resultado del worker async: (generación, Ok(items) | Err(mensaje))
AsyncLoaded(u64, Result<Vec<SelectItem>, String>),
}
enum AsyncState {
Idle,
Loading,
Error(String),
Ready(Vec<SelectItem>),
}
struct Model {
theme: Theme,
open: Option<usize>,
active: usize, // posición en visible
query: String,
estado_items: Vec<SelectItem>,
estado_sel: Option<usize>,
persona_items: Vec<SelectItem>,
persona_sel: Option<usize>,
async_state: AsyncState,
async_sel: Option<usize>,
async_gen: u64,
async_attempts: u32,
}
impl Model {
/// Ítems del select abierto (para filtro/navegación), si aplica.
fn open_items(&self) -> Option<&[SelectItem]> {
match self.open? {
SEL_ESTADO => Some(&self.estado_items),
SEL_PERSONA => Some(&self.persona_items),
SEL_ASYNC => match &self.async_state {
AsyncState::Ready(items) => Some(items),
_ => None,
},
_ => None,
}
}
fn is_searchable(open: usize) -> bool {
open == SEL_PERSONA || open == SEL_ASYNC
}
fn visible(&self) -> Vec<usize> {
match self.open_items() {
Some(items) => filter(items, &self.query),
None => Vec::new(),
}
}
}
fn estado_items() -> Vec<SelectItem> {
vec![
SelectItem::new("Pendiente").icon("\u{25CB}").badge(SelectBadge::dot(BadgeKind::Warning)),
SelectItem::new("En curso").icon("\u{25D0}").badge(SelectBadge::dot(BadgeKind::Info)),
SelectItem::new("Bloqueado").icon("\u{25A0}").disabled(),
SelectItem::new("Hecho").icon("\u{25CF}").badge(SelectBadge::dot(BadgeKind::Success)),
]
}
fn persona_items() -> Vec<SelectItem> {
vec![
SelectItem::new("Sergio Luna")
.icon("\u{25C9}")
.with_sublabel("gerencia · dueño")
.badge(SelectBadge::count(12, BadgeKind::Info)),
SelectItem::new("Ana Quispe")
.icon("\u{25C9}")
.with_sublabel("backend")
.badge(SelectBadge::count(3, BadgeKind::Neutral)),
SelectItem::new("Beto Mamani")
.icon("\u{25C9}")
.with_sublabel("diseño")
.badge(SelectBadge::label("beta", BadgeKind::Warning)),
SelectItem::new("Carmen Rojas")
.icon("\u{25C9}")
.with_sublabel("infra · de licencia")
.disabled(),
SelectItem::new("Diego Flores")
.icon("\u{25C9}")
.with_sublabel("qa")
.badge(SelectBadge::count(120, BadgeKind::Error)),
]
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn init(_: &Handle<Msg>) -> Model {
Model {
theme: Theme::dark(),
open: None,
active: usize::MAX,
query: String::new(),
estado_items: estado_items(),
estado_sel: Some(0),
persona_items: persona_items(),
persona_sel: None,
async_state: AsyncState::Idle,
async_sel: None,
async_gen: 0,
async_attempts: 0,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Toggle(which) => {
if model.open == Some(which) {
model.open = None;
} else {
model.open = Some(which);
model.query.clear();
model.active = usize::MAX;
// Abrir el async dispara la carga si no hay datos.
if which == SEL_ASYNC && !matches!(model.async_state, AsyncState::Ready(_)) {
model = start_load(model, handle);
}
}
}
Msg::Dismiss => model.open = None,
Msg::Hover(pos) => model.active = pos,
Msg::Pick(which, orig) => {
match which {
SEL_ESTADO => model.estado_sel = Some(orig),
SEL_PERSONA => model.persona_sel = Some(orig),
SEL_ASYNC => model.async_sel = Some(orig),
_ => {}
}
model.open = None;
}
Msg::Retry => {
if model.open == Some(SEL_ASYNC) {
model = start_load(model, handle);
}
}
Msg::AsyncLoaded(gen, result) => {
// Guard de generación: descartar respuestas de cargas viejas.
if gen == model.async_gen {
model.async_state = match result {
Ok(items) => AsyncState::Ready(items),
Err(e) => AsyncState::Error(e),
};
}
}
Msg::Key(ev) => {
if ev.state != KeyState::Pressed {
return model;
}
let Some(which) = model.open else { return model };
match &ev.key {
Key::Named(NamedKey::Escape) => model.open = None,
Key::Named(NamedKey::ArrowDown) => {
if let Some(items) = model.open_items() {
let vis = filter(items, &model.query);
model.active = step_active(items, &vis, model.active, 1);
}
}
Key::Named(NamedKey::ArrowUp) => {
if let Some(items) = model.open_items() {
let vis = filter(items, &model.query);
model.active = step_active(items, &vis, model.active, -1);
}
}
Key::Named(NamedKey::Enter) => {
let vis = model.visible();
if let Some(orig) = resolve(&vis, model.active) {
return Self::update(model, Msg::Pick(which, orig), handle);
}
}
Key::Named(NamedKey::Backspace) if Model::is_searchable(which) => {
model.query.pop();
model.active = usize::MAX;
}
_ => {
if Model::is_searchable(which) {
if let Some(text) = &ev.text {
if !text.is_empty() && !text.chars().any(|c| c.is_control()) {
model.query.push_str(text);
model.active = usize::MAX;
}
}
}
}
}
}
}
model
}
fn on_key(_: &Model, ev: &KeyEvent) -> Option<Msg> {
Some(Msg::Key(ev.clone()))
}
fn view(model: &Model) -> View<Msg> {
let pal = SelectPalette::from_theme(&model.theme);
let title = View::new(Style {
position: Position::Absolute,
inset: Rect { left: length(X), top: length(40.0_f32), right: auto(), bottom: auto() },
size: Size { width: length(640.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"llimphi-widget-select — simple · buscable+badges · async".to_string(),
16.0,
model.theme.fg_text,
Alignment::Start,
);
let estado = labeled_trigger(
"Estado (simple)",
SEL_ESTADO,
select_trigger_view(
model.estado_sel.and_then(|i| model.estado_items.get(i)),
"Elegí un estado…",
model.open == Some(SEL_ESTADO),
Some(W),
&pal,
Msg::Toggle(SEL_ESTADO),
),
&model.theme,
0,
);
let persona = labeled_trigger(
"Asignar a (buscable · badges)",
SEL_PERSONA,
select_trigger_view(
model.persona_sel.and_then(|i| model.persona_items.get(i)),
"Buscar persona…",
model.open == Some(SEL_PERSONA),
Some(W),
&pal,
Msg::Toggle(SEL_PERSONA),
),
&model.theme,
1,
);
let async_selected = match (&model.async_state, model.async_sel) {
(AsyncState::Ready(items), Some(i)) => items.get(i),
_ => None,
};
let async_t = labeled_trigger(
"Repositorio (carga async)",
SEL_ASYNC,
select_trigger_view(
async_selected,
"Cargar repos…",
model.open == Some(SEL_ASYNC),
Some(W),
&pal,
Msg::Toggle(SEL_ASYNC),
),
&model.theme,
2,
);
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(model.theme.bg_app)
.children(vec![title, estado, persona, async_t])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
let which = model.open?;
let pal = SelectPalette::from_theme(&model.theme);
let anchor = (X, Y0 + which as f32 * ROW + TRIGGER_H + 6.0);
let visible = model.visible();
let phase = match which {
SEL_ESTADO => SelectPhase::Ready(&model.estado_items),
SEL_PERSONA => SelectPhase::Ready(&model.persona_items),
SEL_ASYNC => match &model.async_state {
AsyncState::Loading | AsyncState::Idle => SelectPhase::Loading,
AsyncState::Error(e) => SelectPhase::Error(e),
AsyncState::Ready(items) => SelectPhase::Ready(items),
},
_ => return None,
};
let selected: Vec<usize> = match which {
SEL_ESTADO => model.estado_sel.into_iter().collect(),
SEL_PERSONA => model.persona_sel.into_iter().collect(),
SEL_ASYNC => model.async_sel.into_iter().collect(),
_ => Vec::new(),
};
Some(select_menu_view(SelectMenuSpec {
anchor,
viewport: (980.0, 640.0),
width: W,
phase,
visible: &visible,
active: model.active,
selected: &selected,
query: &model.query,
searchable: Model::is_searchable(which),
empty_text: "Sin coincidencias",
appear: 1.0,
on_pick: std::sync::Arc::new(move |orig| Msg::Pick(which, orig)),
on_hover: Some(std::sync::Arc::new(Msg::Hover)),
on_dismiss: Msg::Dismiss,
on_retry: Some(Msg::Retry),
palette: &pal,
}))
}
}
/// Lanza la carga async: incrementa la generación, marca Loading y dispara
/// un worker que duerme ~900 ms. El primer intento falla a propósito (para
/// mostrar el estado de error + Reintentar); del segundo en adelante trae
/// los datos.
fn start_load(mut model: Model, handle: &Handle<Msg>) -> Model {
model.async_state = AsyncState::Loading;
model.async_gen = model.async_gen.wrapping_add(1);
model.async_attempts += 1;
let gen = model.async_gen;
let attempt = model.async_attempts;
handle.spawn(move || {
std::thread::sleep(Duration::from_millis(900));
if attempt == 1 {
Msg::AsyncLoaded(gen, Err("No se pudo contactar el índice".to_string()))
} else {
Msg::AsyncLoaded(
gen,
Ok(vec![
SelectItem::new("tawasuyu")
.icon("\u{2756}")
.with_sublabel("rust · 210 crates")
.badge(SelectBadge::count(42, BadgeKind::Info)),
SelectItem::new("llimphi")
.icon("\u{2756}")
.with_sublabel("motor gráfico")
.badge(SelectBadge::label("ui", BadgeKind::Success)),
SelectItem::new("wawa")
.icon("\u{2756}")
.with_sublabel("SO bare-metal")
.badge(SelectBadge::dot(BadgeKind::Warning)),
SelectItem::new("sigma")
.icon("\u{2756}")
.with_sublabel("gestión escolar")
.badge(SelectBadge::count(7, BadgeKind::Neutral)),
]),
)
}
});
model
}
/// Envuelve un trigger en un bloque absoluto con rótulo arriba, en la
/// posición canónica del select `i`.
fn labeled_trigger(
label: &str,
_which: usize,
trigger: View<Msg>,
theme: &Theme,
i: usize,
) -> View<Msg> {
let y = Y0 + i as f32 * ROW;
View::new(Style {
position: Position::Absolute,
inset: Rect { left: length(X), top: length(y - 22.0), right: auto(), bottom: auto() },
size: Size { width: length(W), height: length(ROW) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0_f32), height: length(6.0_f32) },
..Default::default()
})
.children(vec![
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(16.0_f32) },
..Default::default()
})
.text_aligned(label.to_string(), 11.5, theme.fg_muted, Alignment::Start),
trigger,
])
}
fn main() {
llimphi_ui::run::<Demo>();
}