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
+359
View File
@@ -0,0 +1,359 @@
//! llimphi-text — Texto sobre vello vía parley.
//!
//! parley hace shaping completo (bidi, ligatures, kerning), line break y
//! alineación; fontique resuelve fuentes del sistema con fallback CJK/emoji.
//! Aquí lo envolvemos en una API mínima centrada en el caso común: un
//! bloque de texto con color uniforme, ancho máximo opcional y alineación.
use vello::peniko::{Brush, Color};
pub use parley;
pub use vello;
pub use vello::peniko;
/// Estado compartido del motor de texto. Una instancia por proceso es lo
/// recomendado: `FontContext` cachea la base de fuentes y `LayoutContext`
/// reutiliza allocaciones entre layouts.
pub struct Typesetter {
font_cx: parley::FontContext,
layout_cx: parley::LayoutContext<()>,
/// Contexto separado para layouts multicolor (`Brush` por rango). El
/// brush genérico de parley no puede ser `()` y `RunBrush` a la vez en
/// el mismo `LayoutContext`, así que mantenemos uno por sabor.
runs_cx: parley::LayoutContext<RunBrush>,
}
impl Default for Typesetter {
fn default() -> Self {
Self::new()
}
}
/// DejaVu Sans embebida como **fallback universal de símbolos**. El motor
/// confía en las fuentes del sistema vía fontique, pero muchas instalaciones
/// (p. ej. solo Liberation/Adwaita) carecen de glyphs para flechas (`→`),
/// formas geométricas (`● ▶`), dingbats (`✓ ✗ ✎`), avisos (`⚠`) o astro
/// (`♈ ☉ ☽`) — y entonces parley pinta el "tofu" (□). DejaVu cubre todo ese
/// rango; la registramos y la enganchamos al fallback del script `Common`
/// (`Zyyy`), que es donde Unicode clasifica esos símbolos. Así cualquier app
/// Llimphi deja de mostrar cuadrados sin tocar una línea de su código.
/// Licencia: Bitstream Vera + Arev (libre, redistribuible).
const DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
impl Typesetter {
pub fn new() -> Self {
let mut font_cx = parley::FontContext::new();
Self::install_symbol_fallback(&mut font_cx);
Self {
font_cx,
layout_cx: parley::LayoutContext::new(),
runs_cx: parley::LayoutContext::new(),
}
}
/// Registra DejaVu Sans y la apila como último recurso para los símbolos
/// del script `Common` (flechas, geométricos, dingbats, astro…). Ver la
/// nota de [`DEJAVU_SANS`]. Best-effort: si algo falla, el texto sigue
/// funcionando con las fuentes del sistema (solo reaparecería el tofu).
fn install_symbol_fallback(font_cx: &mut parley::FontContext) {
use parley::fontique::Blob;
let blob = Blob::new(std::sync::Arc::new(DEJAVU_SANS));
let registered = font_cx.collection.register_fonts(blob, None);
if let Some((family_id, _)) = registered.first() {
// `Zyyy` (Common) es el script de la inmensa mayoría de los
// símbolos que daban tofu; lo apilamos al final del fallback.
font_cx
.collection
.append_fallbacks("Zyyy", std::iter::once(*family_id));
}
}
/// Acceso al `FontContext` por si se necesita registrar fuentes extra
/// o cambiar la stack de fallback.
pub fn font_context_mut(&mut self) -> &mut parley::FontContext {
&mut self.font_cx
}
/// Construye y resuelve un `parley::Layout`. Aplica `font_size`,
/// `line_height` (multiplicador del font_size), `max_width` (line
/// break), y `alignment`. `italic`=true selecciona la variante
/// italic/oblique de la fuente activa (vía `parley::FontStyle`).
pub fn layout(
&mut self,
text: &str,
size_px: f32,
max_width: Option<f32>,
alignment: Alignment,
line_height: f32,
italic: bool,
font_family: Option<&str>,
) -> parley::Layout<()> {
let mut builder =
self.layout_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(line_height));
if italic {
builder.push_default(parley::StyleProperty::FontStyle(
parley::FontStyle::Italic,
));
}
if let Some(ff) = font_family {
// parley::FontStack::Source acepta CSS-like syntax
// (`"Helvetica", sans-serif`).
builder.push_default(parley::StyleProperty::FontStack(
parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
));
}
let mut layout = builder.build(text);
layout.break_all_lines(max_width);
layout.align(
max_width,
alignment.into(),
parley::AlignmentOptions::default(),
);
layout
}
/// Construye un layout **multicolor** en una sola pasada de shaping:
/// `default_color` cubre todo el texto y cada `(start_byte, end_byte,
/// color)` lo sobreescribe en su rango (offsets en **bytes**, no chars —
/// la convención de parley). Pensado para syntax highlighting: shapear
/// la línea entera una vez con un color por token, en vez de un layout
/// por token. Sin wrap (`max_width = None`); el caller posiciona la línea.
pub fn layout_runs(
&mut self,
text: &str,
size_px: f32,
default_color: Color,
runs: &[(usize, usize, Color)],
alignment: Alignment,
line_height: f32,
) -> parley::Layout<RunBrush> {
let mut builder = self
.runs_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(line_height));
builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
let len = text.len();
for &(start, end, color) in runs {
if start < end && end <= len {
builder.push(parley::StyleProperty::Brush(RunBrush(color)), start..end);
}
}
let mut layout = builder.build(text);
layout.break_all_lines(None);
layout.align(None, alignment.into(), parley::AlignmentOptions::default());
layout
}
}
/// Brush por-run para texto multicolor. Newtype sobre [`Color`] porque
/// parley exige que el brush genérico implemente `Default` (que `Color` no
/// garantiza); aquí proveemos uno explícito (negro opaco) que nunca se ve
/// en la práctica: todo run lleva su color o el `default_color` del bloque.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct RunBrush(pub Color);
impl Default for RunBrush {
fn default() -> Self {
RunBrush(Color::from_rgba8(0, 0, 0, 255))
}
}
/// Alineación horizontal del bloque dentro de su ancho máximo.
#[derive(Debug, Clone, Copy)]
pub enum Alignment {
Start,
Center,
End,
Justify,
}
impl From<Alignment> for parley::Alignment {
fn from(a: Alignment) -> Self {
match a {
Alignment::Start => parley::Alignment::Start,
Alignment::Center => parley::Alignment::Middle,
Alignment::End => parley::Alignment::End,
Alignment::Justify => parley::Alignment::Justified,
}
}
}
/// Especificación de un bloque de texto a rasterizar.
pub struct TextBlock<'a> {
pub text: &'a str,
pub size_px: f32,
pub color: Color,
/// Esquina superior-izquierda del bloque (no el baseline — parley se
/// encarga del baseline internamente).
pub origin: (f64, f64),
pub max_width: Option<f32>,
pub alignment: Alignment,
/// Múltiplo del font_size (1.0 = compacto, 1.3 = cómodo).
pub line_height: f32,
/// `true` → fuerza variante italic/oblique en la fuente activa.
pub italic: bool,
/// CSS-style `font-family` string. `None` = sans-serif default.
pub font_family: Option<String>,
}
impl<'a> TextBlock<'a> {
/// Constructor simple para una línea sin wrap.
pub fn simple(text: &'a str, size_px: f32, color: Color, origin: (f64, f64)) -> Self {
Self {
text,
size_px,
color,
origin,
max_width: None,
alignment: Alignment::Start,
line_height: 1.0,
italic: false,
font_family: None,
}
}
}
/// Medidas resultantes de un layout.
#[derive(Debug, Clone, Copy)]
pub struct Measurement {
pub width: f32,
pub height: f32,
}
/// Construye el layout (shaping + line break + alineación) listo para medir
/// y/o pintar. Usá esta API cuando necesitás el alto **antes** de elegir el
/// origen (p. ej. centrado vertical) y no querés repetir el shaping en el
/// `draw`: medís sobre el layout retornado y luego lo pasás a
/// [`draw_layout`].
pub fn layout_block(ts: &mut Typesetter, block: &TextBlock<'_>) -> parley::Layout<()> {
ts.layout(
block.text,
block.size_px,
block.max_width,
block.alignment,
block.line_height,
block.italic,
block.font_family.as_deref(),
)
}
/// Devuelve las medidas de un layout ya resuelto. Equivalente conceptual a
/// `(layout.width(), layout.height())` pero envuelto en [`Measurement`].
pub fn measurement(layout: &parley::Layout<()>) -> Measurement {
Measurement {
width: layout.width(),
height: layout.height(),
}
}
/// Pinta un layout ya resuelto en `scene` con `color` y un offset `origin`
/// (esquina superior-izquierda del bloque). No alloca: los glifos van
/// directo del iterador de parley al builder de vello.
pub fn draw_layout(
scene: &mut vello::Scene,
layout: &parley::Layout<()>,
color: Color,
origin: (f64, f64),
) {
draw_layout_xf(scene, layout, color, vello::kurbo::Affine::translate(origin));
}
/// Igual que [`draw_layout`] pero con una **afín completa** en vez de sólo un
/// desplazamiento: permite pintar texto girado/escalado (p. ej. dentro de un
/// marco rotado en una presentación espacial). El origen del layout (0,0) es el
/// que mapea `transform`; las posiciones de glifo se aplican en ese espacio.
pub fn draw_layout_xf(
scene: &mut vello::Scene,
layout: &parley::Layout<()>,
color: Color,
transform: vello::kurbo::Affine,
) {
draw_layout_brush_xf(scene, layout, &Brush::Solid(color), transform);
}
/// Igual que [`draw_layout_xf`] pero con un [`Brush`] arbitrario en vez de un
/// color sólido: permite rellenar los glifos con un gradiente o una imagen
/// (p. ej. CSS `background-clip: text`). El brush se interpreta en el espacio
/// **local** del layout (origen 0,0) y `transform` lo lleva al lugar final —
/// así un gradiente construido en coords (0,0)-(w,h) queda alineado con los
/// glifos. Para texto normal usá [`draw_layout_xf`] (solid = máxima compat).
pub fn draw_layout_brush_xf(
scene: &mut vello::Scene,
layout: &parley::Layout<()>,
brush: &Brush,
transform: vello::kurbo::Affine,
) {
for line in layout.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
let run = glyph_run.run();
let font = run.font().clone();
let font_size = run.font_size();
scene
.draw_glyphs(&font)
.font_size(font_size)
.brush(brush)
.transform(transform)
.draw(
peniko::Fill::NonZero,
glyph_run.positioned_glyphs().map(|g| vello::Glyph {
id: g.id as u32,
x: g.x,
y: g.y,
}),
);
}
}
}
}
/// Pinta un layout **multicolor** ([`Typesetter::layout_runs`]): cada
/// `glyph_run` usa el color de su propio brush ([`RunBrush`]) en vez de un
/// color uniforme. `origin` es la esquina superior-izquierda del bloque.
pub fn draw_layout_runs(
scene: &mut vello::Scene,
layout: &parley::Layout<RunBrush>,
origin: (f64, f64),
) {
let transform = vello::kurbo::Affine::translate(origin);
for line in layout.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
let brush = Brush::Solid(glyph_run.style().brush.0);
let run = glyph_run.run();
let font = run.font().clone();
let font_size = run.font_size();
scene
.draw_glyphs(&font)
.font_size(font_size)
.brush(&brush)
.transform(transform)
.draw(
peniko::Fill::NonZero,
glyph_run.positioned_glyphs().map(|g| vello::Glyph {
id: g.id as u32,
x: g.x,
y: g.y,
}),
);
}
}
}
}
/// Mide sin pintar. Atajo de [`layout_block`] + [`measurement`] para
/// llamadores que sólo necesitan el bounding box.
pub fn measure(ts: &mut Typesetter, block: &TextBlock<'_>) -> Measurement {
measurement(&layout_block(ts, block))
}
/// Rasteriza el bloque en `scene` haciendo shaping una sola vez. Equivale a
/// `layout_block` + `draw_layout` con `block.origin`.
pub fn draw_block(scene: &mut vello::Scene, ts: &mut Typesetter, block: &TextBlock<'_>) {
let layout = layout_block(ts, block);
draw_layout(scene, &layout, block.color, block.origin);
}