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
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-scroll"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-scroll — área de scroll vertical reutilizable: viewport clipeado + contenido desplazado + barra arrastrable. Stateless (el offset vive en el Model); rueda autocontenida vía View::on_scroll. Helpers puros: clamp_offset, ensure_visible, approach (scroll suave)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+326
View File
@@ -0,0 +1,326 @@
//! `llimphi-widget-scroll` — área de scroll vertical reutilizable.
//!
//! Hasta ahora cada app rearmaba el scroll a mano: `App::on_wheel` + un
//! offset en el `Model` + `clip` + virtualización. Este widget empaqueta
//! ese patrón en un solo builder, **sin estado propio** (el offset sigue
//! viviendo en el `Model`, fiel al bucle Elm):
//!
//! - **viewport clipeado** de alto fijo (`viewport_len`),
//! - **contenido desplazado** `-offset` px (overflow recortado),
//! - **barra de scroll arrastrable** a la derecha (sólo si el contenido
//! excede el viewport),
//! - **rueda autocontenida** vía [`View::on_scroll`]: girar la rueda con
//! el cursor sobre el área emite un `Msg` sin que la app rutee nada por
//! su `on_wheel` global.
//!
//! El caller debe conocer el **alto total del contenido** (`content_len`)
//! y el **alto visible** (`viewport_len`) — igual que `list`/`grid` ya
//! piden la ventana visible. Para contenido de filas uniformes es
//! `n_filas * alto_fila`.
//!
//! ## Convención del callback `on_scroll`
//!
//! `on_scroll` recibe el **delta en px** a sumar al offset (no el offset
//! absoluto): tanto la rueda como el arrastre de la barra emiten deltas,
//! y el caller acumula + clampea en su `update` con [`clamp_offset`]. Es
//! la misma idea que el `splitter` (el handler de drag se reusa durante
//! todo el arrastre, así que un offset absoluto capturado se quedaría
//! viejo; el delta-por-evento siempre es correcto).
//!
//! ```ignore
//! // view:
//! scroll_y(
//! model.offset,
//! model.rows.len() as f32 * ROW_H,
//! panel_h,
//! lista_view,
//! Msg::ScrollBy, // Fn(f32) -> Msg, arg = delta px
//! &ScrollPalette::default(),
//! )
//! // update:
//! Msg::ScrollBy(d) => {
//! m.offset = clamp_offset(m.offset + d, content_len, viewport_len);
//! }
//! ```
//!
//! Para llevar una selección a la vista (teclado), ver [`ensure_visible`];
//! para scroll suave/inercia, ver [`approach`].
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, Position, Rect, Size, Style},
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{DragPhase, View};
/// Alto mínimo del thumb en px — para que no desaparezca con contenido
/// muy largo.
const MIN_THUMB: f32 = 28.0;
/// Px de desplazamiento por "línea" de rueda. Aproxima el step de scroll
/// de un editor (≈3 líneas de texto).
pub const DEFAULT_LINE_PX: f32 = 48.0;
/// Ancho de la barra de scroll en px.
pub const DEFAULT_BAR_WIDTH: f32 = 10.0;
/// Colores de la barra de scroll.
#[derive(Debug, Clone, Copy)]
pub struct ScrollPalette {
/// Canal de fondo (track).
pub track: Color,
/// Pulgar (thumb) en reposo.
pub thumb: Color,
/// Pulgar al pasar el cursor.
pub thumb_hover: Color,
/// Ancho de la barra y px por línea de rueda.
pub bar_width: f32,
pub line_px: f32,
}
impl Default for ScrollPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl ScrollPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
track: t.bg_panel_alt,
thumb: t.border,
thumb_hover: t.accent,
bar_width: DEFAULT_BAR_WIDTH,
line_px: DEFAULT_LINE_PX,
}
}
}
/// Máximo offset posible: cuánto se puede desplazar antes de que el final
/// del contenido toque el borde inferior del viewport. `0` si el contenido
/// entra entero.
pub fn max_offset(content_len: f32, viewport_len: f32) -> f32 {
(content_len - viewport_len).max(0.0)
}
/// Acota `offset` a `[0, max_offset]`. El caller lo usa en su `update`
/// tras sumar el delta de [`scroll_y`].
pub fn clamp_offset(offset: f32, content_len: f32, viewport_len: f32) -> f32 {
offset.clamp(0.0, max_offset(content_len, viewport_len))
}
/// Devuelve el offset que deja **visible** el intervalo vertical
/// `[item_top, item_top + item_h]` dentro de un viewport de alto
/// `viewport_len`, partiendo de `offset`. Si ya está visible, lo devuelve
/// sin cambios. Pensado para "llevar la selección a la vista" al navegar
/// con teclado (flechas, Page Up/Down). El resultado se acota a `≥ 0`; el
/// caller puede clampear arriba con [`clamp_offset`] si lo necesita.
pub fn ensure_visible(offset: f32, viewport_len: f32, item_top: f32, item_h: f32) -> f32 {
if item_top < offset {
// El item arranca por encima del viewport: subí hasta su tope.
item_top.max(0.0)
} else if item_top + item_h > offset + viewport_len {
// El item termina por debajo: bajá hasta que su fondo toque el borde.
(item_top + item_h - viewport_len).max(0.0)
} else {
offset
}
}
/// Un paso de aproximación exponencial de `current` hacia `target`
/// (scroll suave / inercia). `factor ∈ (0, 1]`: 1.0 salta de una, 0.2
/// desliza suave. Cuando la diferencia cae por debajo de 0.5 px aterriza
/// exacto en `target` (evita el "casi-llega" infinito). El caller lo
/// dispara por frame vía `Handle::spawn_periodic` guardando `target` en
/// su `Model`.
pub fn approach(current: f32, target: f32, factor: f32) -> f32 {
let f = factor.clamp(0.0, 1.0);
let next = current + (target - current) * f;
if (target - next).abs() < 0.5 {
target
} else {
next
}
}
/// Geometría del thumb: `(altura, posición_y)` dentro del track de alto
/// `viewport_len`, y `offset_por_px` (cuánto offset de contenido equivale
/// a 1 px de arrastre del thumb). Público para tests y para callers que
/// quieran pintar su propia barra.
pub fn thumb_geometry(offset: f32, content_len: f32, viewport_len: f32) -> (f32, f32, f32) {
let max_off = max_offset(content_len, viewport_len);
if max_off <= 0.0 || content_len <= 0.0 {
return (viewport_len, 0.0, 0.0);
}
let ratio = (viewport_len / content_len).clamp(0.0, 1.0);
let thumb_h = (viewport_len * ratio).clamp(MIN_THUMB.min(viewport_len), viewport_len);
let travel = (viewport_len - thumb_h).max(0.0);
let thumb_y = if max_off > 0.0 {
(offset / max_off).clamp(0.0, 1.0) * travel
} else {
0.0
};
let offset_per_px = if travel > 0.0 { max_off / travel } else { 0.0 };
(thumb_h, thumb_y, offset_per_px)
}
/// Área de scroll vertical. `offset` es el desplazamiento actual (px, ya
/// clampeado por el caller). `content_len`/`viewport_len` el alto total y
/// visible. `content` se desplaza `-offset` y se recorta al viewport.
/// `on_scroll(delta_px)` se invoca con el delta a sumar al offset (rueda
/// y arrastre de barra); el caller acumula con [`clamp_offset`].
pub fn scroll_y<Msg, F>(
offset: f32,
content_len: f32,
viewport_len: f32,
content: View<Msg>,
on_scroll: F,
palette: &ScrollPalette,
) -> View<Msg>
where
// `Msg` no necesita `Send + Sync`: los closures de rueda/arrastre
// capturan el `Arc<dyn Fn + Send + Sync>`, no un `Msg`. Sólo se exige
// `Clone` (para montar el `View`) y `'static`.
Msg: Clone + 'static,
F: Fn(f32) -> Msg + Send + Sync + 'static,
{
let on_scroll = Arc::new(on_scroll);
// Contenido desplazado: nodo absoluto anclado a left/right (toma el
// ancho del viewport) con top = -offset y alto natural. El overflow se
// recorta por el `clip` del viewport.
let content_wrap = View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(-offset),
left: length(0.0),
right: length(0.0),
bottom: auto(),
},
..Default::default()
})
.children(vec![content]);
let mut children = vec![content_wrap];
// Barra: sólo si hay overflow.
if max_offset(content_len, viewport_len) > 0.0 {
let (thumb_h, thumb_y, offset_per_px) =
thumb_geometry(offset, content_len, viewport_len);
let on_thumb = on_scroll.clone();
let thumb = View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(thumb_y),
right: length(0.0),
left: auto(),
bottom: auto(),
},
size: Size {
width: length(palette.bar_width),
height: length(thumb_h),
},
..Default::default()
})
.fill(palette.thumb)
.hover_fill(palette.thumb_hover)
.radius((palette.bar_width * 0.5) as f64)
.draggable(move |phase, _dx, dy| match phase {
// Cada Move trae el delta de px de pantalla del thumb; lo
// convertimos a delta de offset de contenido.
DragPhase::Move => Some((on_thumb)(dy * offset_per_px)),
DragPhase::End => None,
});
let track = View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(0.0),
right: length(0.0),
bottom: length(0.0),
left: auto(),
},
size: Size {
width: length(palette.bar_width),
height: auto(),
},
..Default::default()
})
.fill(palette.track)
.children(vec![thumb]);
children.push(track);
}
// Viewport: alto fijo, ancho del padre, contenido recortado, rueda
// local. Position::Relative para ser el bloque contenedor de los
// hijos absolutos.
let line_px = palette.line_px;
let on_wheel = on_scroll;
View::new(Style {
position: Position::Relative,
size: Size {
width: percent(1.0),
height: length(viewport_len),
},
..Default::default()
})
.clip(true)
.on_scroll(move |_dx, dy| Some((on_wheel)(dy * line_px)))
.children(children)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn max_y_clamp() {
assert_eq!(max_offset(1000.0, 300.0), 700.0);
assert_eq!(max_offset(200.0, 300.0), 0.0); // entra entero
assert_eq!(clamp_offset(-50.0, 1000.0, 300.0), 0.0);
assert_eq!(clamp_offset(9999.0, 1000.0, 300.0), 700.0);
assert_eq!(clamp_offset(400.0, 1000.0, 300.0), 400.0);
}
#[test]
fn ensure_visible_arriba_abajo_y_sin_cambio() {
let vp = 300.0;
// Item por encima del offset → subir hasta su tope.
assert_eq!(ensure_visible(500.0, vp, 100.0, 20.0), 100.0);
// Item por debajo del fondo visible → bajar lo justo.
assert_eq!(ensure_visible(0.0, vp, 400.0, 20.0), 120.0); // 400+20-300
// Item ya visible → sin cambios.
assert_eq!(ensure_visible(50.0, vp, 100.0, 20.0), 50.0);
// Nunca negativo.
assert_eq!(ensure_visible(50.0, vp, -10.0, 20.0), 0.0);
}
#[test]
fn approach_aterriza_exacto() {
// Se acerca pero no salta.
let a = approach(0.0, 100.0, 0.25);
assert!(a > 0.0 && a < 100.0);
// Diferencia < 0.5 px → aterriza exacto.
assert_eq!(approach(99.8, 100.0, 0.25), 100.0);
// factor 1.0 salta de una.
assert_eq!(approach(0.0, 100.0, 1.0), 100.0);
}
#[test]
fn thumb_proporcional_y_topes() {
// Contenido entra entero → thumb cubre todo, sin travel.
let (h, y, opp) = thumb_geometry(0.0, 200.0, 300.0);
assert_eq!((h, y, opp), (300.0, 0.0, 0.0));
// Contenido 3× viewport → thumb ≈ 1/3 (clampeado a MIN_THUMB).
let (h, y, _) = thumb_geometry(0.0, 900.0, 300.0);
assert!((h - 100.0).abs() < 0.01);
assert_eq!(y, 0.0);
// En el máximo offset, el thumb toca el fondo del track.
let max = max_offset(900.0, 300.0);
let (h2, y2, _) = thumb_geometry(max, 900.0, 300.0);
assert!((y2 + h2 - 300.0).abs() < 0.01);
}
}