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:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "llimphi-layout"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
taffy = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
llimphi-hal = { path = "../llimphi-hal" }
|
||||
llimphi-raster = { path = "../llimphi-raster" }
|
||||
pollster = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "layout_panels"
|
||||
path = "examples/layout_panels.rs"
|
||||
@@ -0,0 +1,10 @@
|
||||
# llimphi-layout
|
||||
|
||||
> Layout taffy + extensiones de [llimphi](../README.md).
|
||||
|
||||
Wrapper sobre `taffy` (Flexbox + Grid) con tipos ergonómicos para `View<Msg>`. Cache del layout calculado entre frames; invalidación dirigida cuando el árbol cambia.
|
||||
|
||||
## Deps
|
||||
|
||||
- `taffy`, `glam`
|
||||
- `serde`
|
||||
@@ -0,0 +1,10 @@
|
||||
# llimphi-layout
|
||||
|
||||
> Taffy layout + extensions of [llimphi](../README.md).
|
||||
|
||||
Wrapper over `taffy` (Flexbox + Grid) with ergonomic types for `View<Msg>`. Cache of computed layout between frames; directed invalidation when the tree changes.
|
||||
|
||||
## Deps
|
||||
|
||||
- `taffy`, `glam`
|
||||
- `serde`
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Fase 3 de Llimphi: 3 paneles (sidebar + header/body/footer) que se
|
||||
//! reorganizan al redimensionar la ventana. Pintados por vello a través
|
||||
//! de llimphi-raster.
|
||||
//!
|
||||
//! Corre con: `cargo run -p llimphi-layout --example layout_panels --release`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use llimphi_hal::winit::application::ApplicationHandler;
|
||||
use llimphi_hal::winit::dpi::LogicalSize;
|
||||
use llimphi_hal::winit::event::WindowEvent;
|
||||
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
|
||||
use llimphi_hal::{Hal, Surface, WinitSurface};
|
||||
use llimphi_layout::{
|
||||
taffy::{prelude::*, Style},
|
||||
ComputedLayout, LayoutTree, Rect,
|
||||
};
|
||||
use llimphi_raster::kurbo::{Affine, RoundedRect};
|
||||
use llimphi_raster::peniko::{color::palette, Color, Fill};
|
||||
use llimphi_raster::{vello, Renderer};
|
||||
|
||||
struct Panels {
|
||||
sidebar: NodeId,
|
||||
header: NodeId,
|
||||
body: NodeId,
|
||||
footer: NodeId,
|
||||
root: NodeId,
|
||||
}
|
||||
|
||||
struct State {
|
||||
window: Arc<Window>,
|
||||
hal: Hal,
|
||||
surface: WinitSurface,
|
||||
renderer: Renderer,
|
||||
scene: vello::Scene,
|
||||
layout: LayoutTree,
|
||||
panels: Panels,
|
||||
}
|
||||
|
||||
struct App {
|
||||
state: Option<State>,
|
||||
}
|
||||
|
||||
fn build_tree(layout: &mut LayoutTree) -> Panels {
|
||||
let sidebar = layout
|
||||
.leaf(Style {
|
||||
size: Size {
|
||||
width: length(220.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let header = layout
|
||||
.leaf(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(64.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let body = layout
|
||||
.leaf(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: Dimension::auto(),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let footer = layout
|
||||
.leaf(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(40.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let content = layout
|
||||
.node(
|
||||
Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
flex_grow: 1.0,
|
||||
size: Size {
|
||||
width: Dimension::auto(),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(0.0_f32),
|
||||
height: length(8.0_f32),
|
||||
},
|
||||
padding: Rect_(length(8.0_f32)),
|
||||
..Default::default()
|
||||
},
|
||||
&[header, body, footer],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = layout
|
||||
.node(
|
||||
Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
&[sidebar, content],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Panels {
|
||||
sidebar,
|
||||
header,
|
||||
body,
|
||||
footer,
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper para pasar el mismo length a todos los lados de un Rect.
|
||||
#[allow(non_snake_case)]
|
||||
fn Rect_(v: LengthPercentage) -> taffy::Rect<LengthPercentage> {
|
||||
taffy::Rect {
|
||||
left: v,
|
||||
right: v,
|
||||
top: v,
|
||||
bottom: v,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(scene: &mut vello::Scene, computed: &ComputedLayout, panels: &Panels) {
|
||||
fn rect(scene: &mut vello::Scene, r: Rect, color: Color, radius: f64) {
|
||||
let rr = RoundedRect::new(
|
||||
r.x as f64,
|
||||
r.y as f64,
|
||||
(r.x + r.w) as f64,
|
||||
(r.y + r.h) as f64,
|
||||
radius,
|
||||
);
|
||||
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &rr);
|
||||
}
|
||||
|
||||
if let Some(r) = computed.get(panels.sidebar) {
|
||||
rect(scene, r, Color::from_rgba8(36, 44, 60, 255), 0.0);
|
||||
}
|
||||
if let Some(r) = computed.get(panels.header) {
|
||||
rect(scene, r, Color::from_rgba8(60, 80, 110, 255), 8.0);
|
||||
}
|
||||
if let Some(r) = computed.get(panels.body) {
|
||||
rect(scene, r, Color::from_rgba8(80, 110, 150, 255), 8.0);
|
||||
}
|
||||
if let Some(r) = computed.get(panels.footer) {
|
||||
rect(scene, r, Color::from_rgba8(60, 80, 110, 255), 8.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.state.is_some() {
|
||||
return;
|
||||
}
|
||||
let window = event_loop
|
||||
.create_window(
|
||||
WindowAttributes::default()
|
||||
.with_title("llimphi · layout_panels")
|
||||
.with_inner_size(LogicalSize::new(960u32, 540u32)),
|
||||
)
|
||||
.expect("create window");
|
||||
let window = Arc::new(window);
|
||||
let hal = pollster::block_on(Hal::new(None)).expect("hal");
|
||||
let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
|
||||
let renderer = Renderer::new(&hal).expect("renderer");
|
||||
let mut layout = LayoutTree::new();
|
||||
let panels = build_tree(&mut layout);
|
||||
window.request_redraw();
|
||||
self.state = Some(State {
|
||||
window,
|
||||
hal,
|
||||
surface,
|
||||
renderer,
|
||||
scene: vello::Scene::new(),
|
||||
layout,
|
||||
panels,
|
||||
});
|
||||
}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
_id: WindowId,
|
||||
event: WindowEvent,
|
||||
) {
|
||||
let Some(state) = self.state.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => {
|
||||
state.surface.resize(size.width, size.height);
|
||||
state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
let frame = match state.surface.acquire() {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
let (w, h) = state.surface.size();
|
||||
state.surface.resize(w, h);
|
||||
state.window.request_redraw();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (w, h) = frame.size();
|
||||
let computed = state
|
||||
.layout
|
||||
.compute(state.panels.root, (w as f32, h as f32))
|
||||
.expect("compute layout");
|
||||
state.scene.reset();
|
||||
paint(&mut state.scene, &computed, &state.panels);
|
||||
if let Err(e) = state.renderer.render(
|
||||
&state.hal,
|
||||
&state.scene,
|
||||
&frame,
|
||||
palette::css::BLACK,
|
||||
) {
|
||||
eprintln!("render error: {e}");
|
||||
}
|
||||
state.surface.present(frame, &state.hal);
|
||||
state.window.request_redraw();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let event_loop = EventLoop::new().expect("event loop");
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
let mut app = App { state: None };
|
||||
event_loop.run_app(&mut app).expect("run app");
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! llimphi-layout — Física del Espacio.
|
||||
//!
|
||||
//! Wrapper sobre `taffy` que resuelve árboles flex/grid y devuelve
|
||||
//! coordenadas absolutas (no relativas al padre). El consumidor pasa el
|
||||
//! árbol a `compute(root, viewport)` y obtiene un [`ComputedLayout`] con
|
||||
//! un rect absoluto por nodo, listo para `llimphi-raster`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub use taffy;
|
||||
pub use taffy::prelude::*;
|
||||
|
||||
/// Errores del motor de layout.
|
||||
#[derive(Debug)]
|
||||
pub enum LayoutError {
|
||||
Taffy(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LayoutError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Taffy(s) => write!(f, "taffy: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LayoutError {}
|
||||
|
||||
/// Caja absoluta de un nodo (origen en la esquina superior izquierda del viewport).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
/// Resultado de [`LayoutTree::compute`]: rect absoluto por nodo del árbol.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ComputedLayout {
|
||||
pub rects: HashMap<NodeId, Rect>,
|
||||
}
|
||||
|
||||
impl ComputedLayout {
|
||||
pub fn get(&self, node: NodeId) -> Option<Rect> {
|
||||
self.rects.get(&node).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Árbol de layout. Encapsula la `TaffyTree` y la lógica de absolutización.
|
||||
pub struct LayoutTree {
|
||||
inner: TaffyTree<()>,
|
||||
}
|
||||
|
||||
impl Default for LayoutTree {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutTree {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: TaffyTree::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vacía el árbol conservando la capacidad ya asignada. Permite
|
||||
/// reusar la misma `LayoutTree` entre frames sin re-allocar el
|
||||
/// slotmap interno de taffy: `clear()` + `mount` en vez de
|
||||
/// `LayoutTree::new()` por frame. Los `NodeId` emitidos antes de
|
||||
/// `clear()` quedan inválidos (el caller ya volcó lo que necesitaba
|
||||
/// a un `ComputedLayout`, que es dueño de sus rects).
|
||||
pub fn clear(&mut self) {
|
||||
self.inner.clear();
|
||||
}
|
||||
|
||||
/// Crea una hoja (nodo sin hijos).
|
||||
pub fn leaf(&mut self, style: Style) -> Result<NodeId, LayoutError> {
|
||||
self.inner
|
||||
.new_leaf(style)
|
||||
.map_err(|e| LayoutError::Taffy(e.to_string()))
|
||||
}
|
||||
|
||||
/// Crea un nodo contenedor con hijos.
|
||||
pub fn node(&mut self, style: Style, children: &[NodeId]) -> Result<NodeId, LayoutError> {
|
||||
self.inner
|
||||
.new_with_children(style, children)
|
||||
.map_err(|e| LayoutError::Taffy(e.to_string()))
|
||||
}
|
||||
|
||||
/// Calcula el layout para `root` con viewport `(w, h)` y devuelve rects absolutos.
|
||||
pub fn compute(
|
||||
&mut self,
|
||||
root: NodeId,
|
||||
viewport: (f32, f32),
|
||||
) -> Result<ComputedLayout, LayoutError> {
|
||||
self.inner
|
||||
.compute_layout(
|
||||
root,
|
||||
taffy::Size {
|
||||
width: AvailableSpace::Definite(viewport.0),
|
||||
height: AvailableSpace::Definite(viewport.1),
|
||||
},
|
||||
)
|
||||
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
|
||||
let mut out = ComputedLayout::default();
|
||||
flatten(&self.inner, root, 0.0, 0.0, &mut out.rects)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Como [`Self::compute`] pero pasando una función de medición por
|
||||
/// nodo. Taffy la invoca sobre las **hojas** que necesita dimensionar
|
||||
/// (texto que envuelve, contenido intrínseco) con el `NodeId`, las
|
||||
/// dimensiones ya conocidas y el espacio disponible; el caller devuelve
|
||||
/// el tamaño en px. Devolver `Size::ZERO` deja que el estilo decida (el
|
||||
/// comportamiento de [`Self::compute`] para hojas sin contenido). El
|
||||
/// `NodeId` permite al caller mantener su propio mapa nodo→contenido
|
||||
/// (p. ej. texto a shapear con parley) sin acoplar este crate a la capa
|
||||
/// de tipografía.
|
||||
pub fn compute_with_measure<F>(
|
||||
&mut self,
|
||||
root: NodeId,
|
||||
viewport: (f32, f32),
|
||||
mut measure: F,
|
||||
) -> Result<ComputedLayout, LayoutError>
|
||||
where
|
||||
F: FnMut(NodeId, taffy::Size<Option<f32>>, taffy::Size<AvailableSpace>) -> taffy::Size<f32>,
|
||||
{
|
||||
self.inner
|
||||
.compute_layout_with_measure(
|
||||
root,
|
||||
taffy::Size {
|
||||
width: AvailableSpace::Definite(viewport.0),
|
||||
height: AvailableSpace::Definite(viewport.1),
|
||||
},
|
||||
|known, available, node_id, _ctx, _style| {
|
||||
measure(node_id, known, available)
|
||||
},
|
||||
)
|
||||
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
|
||||
let mut out = ComputedLayout::default();
|
||||
flatten(&self.inner, root, 0.0, 0.0, &mut out.rects)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn inner(&self) -> &TaffyTree<()> {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
pub fn inner_mut(&mut self) -> &mut TaffyTree<()> {
|
||||
&mut self.inner
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(
|
||||
tree: &TaffyTree<()>,
|
||||
node: NodeId,
|
||||
ox: f32,
|
||||
oy: f32,
|
||||
out: &mut HashMap<NodeId, Rect>,
|
||||
) -> Result<(), LayoutError> {
|
||||
let layout = tree
|
||||
.layout(node)
|
||||
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
|
||||
let x = ox + layout.location.x;
|
||||
let y = oy + layout.location.y;
|
||||
out.insert(
|
||||
node,
|
||||
Rect {
|
||||
x,
|
||||
y,
|
||||
w: layout.size.width,
|
||||
h: layout.size.height,
|
||||
},
|
||||
);
|
||||
let children = tree
|
||||
.children(node)
|
||||
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
|
||||
for child in children {
|
||||
flatten(tree, child, x, y, out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user