feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "llimphi-widget-slider"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-slider — slider horizontal con etiqueta + track draggable + valor numérico. El track es un fillbar (sin pulgar): cambia el ancho relleno según la fracción `(value-min)/(max-min)`. El drag emite el delta de valor (no pixels) en cada `Move`, listo para reentrar al update."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
[[example]]
name = "slider_demo"
path = "examples/slider_demo.rs"
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-slider
> Slider con tick marks para [llimphi](../../README.md).
Horizontal/vertical. Range custom, snap-to-ticks opcional, label de valor en vivo. Continuous y stepped variants.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-slider
> Slider with tick marks for [llimphi](../../README.md).
Horizontal/vertical. Custom range, optional snap-to-ticks, live value label. Continuous and stepped variants.
+130
View File
@@ -0,0 +1,130 @@
//! Showcase de `llimphi-widget-slider`: tres sliders sobre un Model que
//! acumula deltas en vivo. Corré con:
//!
//! ```text
//! cargo run -p llimphi-widget-slider --example slider_demo
//! ```
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, DragPhase, Handle, View};
use llimphi_widget_slider::{slider_view, SliderPalette};
#[derive(Clone, Debug)]
enum Msg {
EditPsique(f32),
EditMateria(f32),
EditPoder(f32),
}
struct Model {
psique: f32,
materia: f32,
poder: f32,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · slider demo"
}
fn initial_size() -> (u32, u32) {
(520, 280)
}
fn init(_: &Handle<Msg>) -> Model {
Model { psique: 0.0, materia: 0.5, poder: -0.25 }
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::EditPsique(dv) => m.psique = (m.psique + dv).clamp(-1.0, 1.0),
Msg::EditMateria(dv) => m.materia = (m.materia + dv).clamp(-1.0, 1.0),
Msg::EditPoder(dv) => m.poder = (m.poder + dv).clamp(-1.0, 1.0),
}
m
}
fn view(model: &Model) -> View<Msg> {
let theme = Theme::dark();
let palette = SliderPalette::from_theme(&theme);
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"ajustá los sliders — el Model acumula deltas en vivo".to_string(),
13.0,
theme.fg_text,
Alignment::Start,
);
let psique = slider_view(
"psique",
model.psique,
-1.0,
1.0,
&palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::EditPsique(dv)),
DragPhase::End => None,
},
);
let materia = slider_view(
"materia",
model.materia,
-1.0,
1.0,
&palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::EditMateria(dv)),
DragPhase::End => None,
},
);
let poder = slider_view(
"poder",
model.poder,
-1.0,
1.0,
&palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::EditPoder(dv)),
DragPhase::End => None,
},
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
padding: Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
align_items: Some(AlignItems::Stretch),
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, psique, materia, poder])
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+254
View File
@@ -0,0 +1,254 @@
//! `llimphi-widget-slider` — slider horizontal con label + track + valor.
//!
//! Pattern análogo a `llimphi-widget-splitter`: el widget no mantiene
//! estado. El caller guarda el valor actual en su `Model` y le pasa un
//! handler `Fn(DragPhase, f32) -> Option<Msg>` que recibe **el delta de
//! valor** (no el delta de pixels) entre eventos consecutivos. El widget
//! traduce internamente `dx_pixels` a `dv` usando `track_width`.
//!
//! Visualmente es un *fillbar*: el track entero es draggable y se rellena
//! una fracción proporcional a `(value - min) / (max - min)`. No hay
//! pulgar separado — el límite entre relleno y vacío es el indicador.
//!
//! Layout fila:
//!
//! ```text
//! [ label_width ] [ ████░░░░░░ ] [ value_width ]
//! "psique" 0.4 / 1.0 " 0.40"
//! ```
//!
//! Uso típico (sliders sobre `LayerMods` de un Concepto):
//!
//! ```ignore
//! slider_view(
//! "psique",
//! model.selected.mods.psique,
//! -1.0, 1.0,
//! &palette,
//! |phase, dv| match phase {
//! DragPhase::Move => Some(Msg::EditMod(Layer::Psique, dv)),
//! DragPhase::End => None,
//! },
//! )
//! ```
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{DragPhase, View};
/// Paleta del slider. Las dimensiones también viajan acá porque definen
/// el layout fila — el caller no toca el `Style` del slider directamente.
#[derive(Debug, Clone, Copy)]
pub struct SliderPalette {
pub track: Color,
pub track_filled: Color,
pub track_hover: Color,
pub fg_label: Color,
pub fg_value: Color,
pub radius: f64,
/// Alto total del widget en pixels.
pub row_height: f32,
/// Ancho fijo del bloque del label (a la izquierda).
pub label_width: f32,
/// Ancho fijo del bloque del valor numérico (a la derecha).
pub value_width: f32,
/// Ancho fijo del track draggable (al medio). Único valor que el
/// widget usa para convertir dx_pixels → dv_value.
pub track_width: f32,
/// Grosor (alto) del track en pixels.
pub track_thickness: f32,
}
impl Default for SliderPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl SliderPalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
track: t.bg_button,
track_filled: t.accent,
track_hover: t.bg_button_hover,
fg_label: t.fg_muted,
fg_value: t.fg_text,
radius: 3.0,
row_height: 22.0,
label_width: 80.0,
value_width: 56.0,
track_width: 120.0,
track_thickness: 6.0,
}
}
}
/// Compone un slider horizontal: label + track-fillbar draggable + valor.
///
/// `value`, `min`, `max` son sólo para presentación visual y conversión
/// `dx → dv`; el caller mantiene el estado y aplica el delta en su
/// `update`. El handler recibe `(DragPhase, delta_value)`; devolver
/// `None` deja el drag activo sin emitir Msg.
pub fn slider_view<Msg, F>(
label: impl Into<String>,
value: f32,
min: f32,
max: f32,
palette: &SliderPalette,
on_change: F,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
{
let range = (max - min).max(f32::EPSILON);
let ratio = ((value - min) / range).clamp(0.0, 1.0);
let track_width = palette.track_width.max(1.0);
// Drag: dx_pixels → dv_value. Escala FIJA (no depende del valor actual).
let span = max - min;
let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option<Msg> {
let dv = dx * span / track_width;
on_change(phase, dv)
};
// Bloque del label.
let label_view = View::new(Style {
size: Size {
width: length(palette.label_width),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(label.into(), 12.0, palette.fg_label, Alignment::Start);
// Track draggable: fill = track bg, hijo = porción rellena (accent).
let filled_radius = palette.radius;
let filled = View::new(Style {
size: Size {
width: percent(ratio),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette.track_filled)
.radius(filled_radius)
.paint_with(move |scene, _ts, rect| {
// Gloss superior sobre la stripe accent — la barra se lee como
// luz que avanza, no como rect plano. Mismo patrón button/progress
// (P6/P7). Alpha bajo (40) porque el track es muy delgado (6px
// default) y un sheen fuerte le mete glitter.
use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let x0 = rect.x as f64;
let y0 = rect.y as f64;
let x1 = (rect.x + rect.w) as f64;
let y1 = (rect.y + rect.h) as f64;
let y_mid = y0 + (y1 - y0) * 0.5;
let rr = RoundedRect::new(x0, y0, x1, y1, filled_radius);
let top = Color::from_rgba8(255, 255, 255, 40);
let bot = Color::from_rgba8(255, 255, 255, 0);
let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
.with_stops([top, bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
});
let track = View::new(Style {
size: Size {
width: length(track_width),
height: length(palette.track_thickness),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.track)
.hover_fill(palette.track_hover)
.radius(palette.radius)
.draggable(handler)
.children(vec![filled]);
// Wrapper del track para centrarlo verticalmente sobre la fila.
let track_cell = View::new(Style {
size: Size {
width: length(track_width),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.children(vec![track]);
// Bloque del valor.
let value_text = format_value(value);
let value_view = View::new(Style {
size: Size {
width: length(palette.value_width),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(value_text, 12.0, palette.fg_value, Alignment::End);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(palette.row_height),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![label_view, track_cell, value_view])
}
/// Formato uniforme para los valores: 2 decimales con signo explícito si
/// la magnitud es chica, 1 decimal si es grande. Cabe en `value_width: 56`.
fn format_value(v: f32) -> String {
let abs = v.abs();
if abs >= 1000.0 {
format!("{v:.0}")
} else if abs >= 10.0 {
format!("{v:.1}")
} else {
format!("{v:+.2}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_value_pretty_for_three_regimes() {
assert_eq!(format_value(0.34), "+0.34");
assert_eq!(format_value(-0.10), "-0.10");
assert_eq!(format_value(42.5), "42.5");
assert_eq!(format_value(1234.0), "1234");
}
}