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
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-transport"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-transport — botones de transporte de reproductor (play/pause/prev/next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless: el caller pasa el estado por botón + un handler TransportAction → Msg."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
+402
View File
@@ -0,0 +1,402 @@
//! `llimphi-widget-transport` — botones de **transporte** para reproductores
//! de medios (play/pause/prev/next/seek/volume/mute/repeat/shuffle/speed/
//! snapshot/record/eq).
//!
//! Pattern análogo a `llimphi-widget-timeline`/`-waveform`: el widget **no
//! mantiene estado** del reproductor. El caller arma un
//! [`TransportButton`] por cada botón visible (con el flag que define su
//! estado activo, como `playing` o `muted`), lo pasa a
//! [`transport_button_view`], y recibe el [`TransportAction`] semántico en
//! un closure cuando el usuario clickea. Quien mapea esas acciones al
//! `MediaCommand` propio del dominio es la app.
//!
//! ```text
//! [⏮ ] [⏯ ] [⏭ ] [⏪ ] [⏩ ] [🔊]
//! ```
//!
//! Uso típico (media-app):
//!
//! ```ignore
//! use llimphi_widget_transport::{transport_button_view, TransportAction as Ta,
//! TransportButton as Tb, TransportPalette};
//! let pal = TransportPalette::from_theme(&theme);
//! transport_button_view(
//! Tb::PlayPause { playing: !pause().is_paused() },
//! &pal,
//! |action| match action {
//! Ta::TogglePlay => Msg::Command(MediaCommand::TogglePause),
//! Ta::SeekBy(secs) => Msg::Command(MediaCommand::SeekBy { secs }),
//! /* … */
//! },
//! )
//! ```
//!
//! Para una fila completa el caller mapea su `Vec<TransportButton>` con
//! `.into_iter().map(|b| transport_button_view(b, &pal, on_action.clone()))`
//! y lo pone como `children` de una `View` con `flex_direction: Row` y
//! `gap: TransportPalette::gap`.
#![forbid(unsafe_code)]
use llimphi_icons::{icon_view, Icon};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
/// Acción semántica que el widget reporta cuando el usuario clickea un
/// botón. El caller la traduce al comando propio de su dominio
/// (ej. `MediaCommand::TogglePause`, `MediaCommand::SeekBy { secs }`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TransportAction {
/// Alterna play/pause.
TogglePlay,
/// Detener — usualmente "seek a 0 + pause", pero el widget no opina:
/// el caller decide.
Stop,
/// Pista previa.
Prev,
/// Pista siguiente.
Next,
/// Salto relativo en segundos (signed: negativo = atrás). Entero
/// porque la mayoría de keymaps de transport usan pasos enteros
/// (5/10/30/60 s, paridad VLC); si una app necesita fracciones,
/// puede pasar `secs * k` y dividir al recibir.
SeekBy(i64),
/// Cambio relativo de volumen (signed; unidades de fracción 0..1
/// típicamente).
VolumeBy(f32),
/// Mute on/off.
ToggleMute,
/// Ciclar modo de repetición (Off → One → All → Off…).
CycleRepeat,
/// Shuffle on/off.
ToggleShuffle,
/// Paso de velocidad (±1) — el caller decide la escala.
SpeedStep(i32),
/// Restablecer velocidad a 1.0×.
SpeedReset,
/// Capturar snapshot (frame del video, p.ej.).
Snapshot,
/// Toggle de grabación.
ToggleRecord,
/// Toggle del ecualizador.
ToggleEqualizer,
}
/// Botón de transporte concreto + su estado de pintura. El widget elige
/// el icono y la `TransportAction` a partir de esto.
///
/// Para los pares simétricos (SeekBack/SeekForward, VolumeDown/VolumeUp)
/// el caller pasa el **valor absoluto** del paso; el widget se ocupa del
/// signo cuando arma la acción (`SeekBy(-secs)` / `VolumeBy(-step)`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TransportButton {
/// Play/Pause — el icono refleja `playing`. `active` cuando NO está
/// pausado (paridad VLC: el botón "iluminado" indica reproducción).
PlayPause { playing: bool },
/// Detener (seek a 0). Nunca active.
Stop,
/// Pista previa.
Prev,
/// Pista siguiente.
Next,
/// Saltar atrás `secs` segundos (positivo; el widget niega para
/// armar la acción).
SeekBack { secs: i64 },
/// Saltar adelante `secs` segundos.
SeekForward { secs: i64 },
/// Bajar volumen en `step` (positivo).
VolumeDown { step: f32 },
/// Subir volumen en `step`.
VolumeUp { step: f32 },
/// Toggle mute — `active` cuando está muteado.
Mute { muted: bool },
/// Ciclar repeat — `active` cuando no es "Off".
Repeat { active: bool },
/// Toggle shuffle — `active` cuando está prendido.
Shuffle { active: bool },
/// Bajar velocidad (chevron ↓). Nunca active.
SpeedDown,
/// Subir velocidad (chevron ↑). Nunca active.
SpeedUp,
/// Restablecer velocidad — `active` cuando ya está en 1.0×
/// (paridad VLC: indica "estado nominal").
SpeedReset { is_default: bool },
/// Snapshot (cámara).
Snapshot,
/// Grabar — `active` cuando está grabando. El widget colorea el
/// icono con `palette.fg_record` para señalizar "rec on".
Record { recording: bool },
/// Toggle del EQ — `active` cuando está prendido.
Equalizer { enabled: bool },
}
impl TransportButton {
fn icon(&self) -> Icon {
match self {
Self::PlayPause { playing } => {
if *playing {
Icon::Pause
} else {
Icon::Play
}
}
Self::Stop => Icon::Stop,
Self::Prev => Icon::SkipBack,
Self::Next => Icon::SkipForward,
Self::SeekBack { .. } => Icon::Rewind,
Self::SeekForward { .. } => Icon::FastForward,
Self::VolumeDown { .. } => Icon::Minus,
Self::VolumeUp { .. } => Icon::Plus,
Self::Mute { .. } => Icon::VolumeMute,
Self::Repeat { .. } => Icon::Repeat,
Self::Shuffle { .. } => Icon::Shuffle,
Self::SpeedDown => Icon::ChevronDown,
Self::SpeedUp => Icon::ChevronUp,
Self::SpeedReset { .. } => Icon::Gauge,
Self::Snapshot => Icon::Camera,
Self::Record { .. } => Icon::Record,
Self::Equalizer { .. } => Icon::Equalizer,
}
}
fn action(&self) -> TransportAction {
match self {
Self::PlayPause { .. } => TransportAction::TogglePlay,
Self::Stop => TransportAction::Stop,
Self::Prev => TransportAction::Prev,
Self::Next => TransportAction::Next,
Self::SeekBack { secs } => TransportAction::SeekBy(-*secs),
Self::SeekForward { secs } => TransportAction::SeekBy(*secs),
Self::VolumeDown { step } => TransportAction::VolumeBy(-step),
Self::VolumeUp { step } => TransportAction::VolumeBy(*step),
Self::Mute { .. } => TransportAction::ToggleMute,
Self::Repeat { .. } => TransportAction::CycleRepeat,
Self::Shuffle { .. } => TransportAction::ToggleShuffle,
Self::SpeedDown => TransportAction::SpeedStep(-1),
Self::SpeedUp => TransportAction::SpeedStep(1),
Self::SpeedReset { .. } => TransportAction::SpeedReset,
Self::Snapshot => TransportAction::Snapshot,
Self::Record { .. } => TransportAction::ToggleRecord,
Self::Equalizer { .. } => TransportAction::ToggleEqualizer,
}
}
fn is_active(&self) -> bool {
match self {
Self::PlayPause { playing } => *playing,
Self::Mute { muted } => *muted,
Self::Repeat { active } | Self::Shuffle { active } => *active,
Self::SpeedReset { is_default } => *is_default,
Self::Record { recording } => *recording,
Self::Equalizer { enabled } => *enabled,
_ => false,
}
}
fn is_record(&self) -> bool {
matches!(self, Self::Record { .. })
}
}
/// Paleta + dimensiones de los botones del transport. Las medidas viven
/// acá porque definen la silueta de la barra; el caller no toca el
/// `Style` directamente.
#[derive(Debug, Clone, Copy)]
pub struct TransportPalette {
/// Fondo del botón inactivo.
pub bg: Color,
/// Fondo del botón activo (PlayPause cuando reproduce, Mute cuando
/// muteado, etc.).
pub bg_active: Color,
/// Fondo en hover.
pub bg_hover: Color,
/// Color del icono inactivo.
pub fg: Color,
/// Color del icono activo.
pub fg_active: Color,
/// Color especial del icono Record (siempre rojo, prendido o no).
pub fg_record: Color,
/// Ancho del botón (px).
pub btn_w: f32,
/// Alto del botón (px).
pub btn_h: f32,
/// Radio de las esquinas.
pub radius: f64,
/// Grosor del stroke del icono (unidades llimphi-icons; típico 1.62.0).
pub icon_stroke: f32,
/// Separación recomendada entre botones cuando el caller los pone
/// en una fila (el widget no la aplica — sólo expone el sugerido).
pub gap: f32,
}
impl Default for TransportPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl TransportPalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_button,
bg_active: t.bg_selected,
bg_hover: t.bg_button_hover,
fg: t.fg_text,
fg_active: t.accent,
// Rojo "REC" universal — no viaja por el theme.
fg_record: Color::from_rgba8(232, 86, 86, 255),
btn_w: 40.0,
btn_h: 34.0,
radius: 8.0,
icon_stroke: 2.0,
gap: 6.0,
}
}
}
/// Compone **un** botón de transporte. El handler `on_action` recibe la
/// [`TransportAction`] semántica cuando el usuario clickea — el caller
/// la traduce al `MediaCommand` (o equivalente) de su dominio.
///
/// El widget no mantiene estado: pasale el `TransportButton` con su
/// estado vigente cada frame y el icono / color / bg-activo se ajusta
/// solo. Para una **fila** de botones, mapeá tu `Vec<TransportButton>`
/// con `.iter().map(|b| transport_button_view(*b, &pal, on_action.clone()))`
/// como children de una `View` con `flex_direction: Row` y
/// `gap: palette.gap`.
pub fn transport_button_view<Msg, F>(
button: TransportButton,
palette: &TransportPalette,
on_action: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(TransportAction) -> Msg + Send + Sync + 'static,
{
let active = button.is_active();
let bg = if active { palette.bg_active } else { palette.bg };
let fg = if button.is_record() {
palette.fg_record
} else if active {
palette.fg_active
} else {
palette.fg
};
let action = button.action();
let icon = button.icon();
View::new(Style {
size: Size {
width: length(palette.btn_w),
height: length(palette.btn_h),
},
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(bg)
.hover_fill(palette.bg_hover)
.radius(palette.radius)
.on_click(on_action(action))
.children(vec![icon_view::<Msg>(icon, fg, palette.icon_stroke)])
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq)]
struct Cmd(TransportAction);
#[test]
fn from_theme_usa_colores_semanticos() {
let t = llimphi_theme::Theme::dark();
let p = TransportPalette::from_theme(&t);
assert_eq!(p.bg, t.bg_button);
assert_eq!(p.bg_active, t.bg_selected);
assert_eq!(p.bg_hover, t.bg_button_hover);
assert_eq!(p.fg, t.fg_text);
assert_eq!(p.fg_active, t.accent);
// El rojo de REC no debe venir del theme.
let [r, _, _, _] = p.fg_record.components;
assert!(r > 0.8, "fg_record debe ser rojo dominante");
}
#[test]
fn play_pause_alterna_icono_y_activa_al_reproducir() {
let on = TransportButton::PlayPause { playing: true };
let off = TransportButton::PlayPause { playing: false };
assert!(matches!(on.icon(), Icon::Pause));
assert!(matches!(off.icon(), Icon::Play));
assert!(on.is_active());
assert!(!off.is_active());
assert_eq!(on.action(), TransportAction::TogglePlay);
assert_eq!(off.action(), TransportAction::TogglePlay);
}
#[test]
fn seek_simetrico_niega_signo_atras() {
let back = TransportButton::SeekBack { secs: 5 };
let fwd = TransportButton::SeekForward { secs: 5 };
assert_eq!(back.action(), TransportAction::SeekBy(-5));
assert_eq!(fwd.action(), TransportAction::SeekBy(5));
}
#[test]
fn volumen_simetrico_niega_signo_abajo() {
let down = TransportButton::VolumeDown { step: 0.1 };
let up = TransportButton::VolumeUp { step: 0.1 };
assert_eq!(down.action(), TransportAction::VolumeBy(-0.1));
assert_eq!(up.action(), TransportAction::VolumeBy(0.1));
}
#[test]
fn record_activo_y_record_es_caso_especial() {
let on = TransportButton::Record { recording: true };
let off = TransportButton::Record { recording: false };
assert!(on.is_active());
assert!(!off.is_active());
// Pero ambos son "record" → ambos usan fg_record.
assert!(on.is_record());
assert!(off.is_record());
}
#[test]
fn speed_reset_active_cuando_default() {
let nominal = TransportButton::SpeedReset { is_default: true };
let off = TransportButton::SpeedReset { is_default: false };
assert!(nominal.is_active());
assert!(!off.is_active());
}
#[test]
fn construye_sin_panic_todos_los_botones() {
let pal = TransportPalette::default();
let buttons = [
TransportButton::PlayPause { playing: false },
TransportButton::Stop,
TransportButton::Prev,
TransportButton::Next,
TransportButton::SeekBack { secs: 5 },
TransportButton::SeekForward { secs: 5 },
TransportButton::VolumeDown { step: 0.1 },
TransportButton::VolumeUp { step: 0.1 },
TransportButton::Mute { muted: false },
TransportButton::Repeat { active: false },
TransportButton::Shuffle { active: false },
TransportButton::SpeedDown,
TransportButton::SpeedUp,
TransportButton::SpeedReset { is_default: true },
TransportButton::Snapshot,
TransportButton::Record { recording: false },
TransportButton::Equalizer { enabled: false },
];
for b in buttons {
let _ = transport_button_view::<Cmd, _>(b, &pal, Cmd);
}
}
}