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
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-workspace"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-workspace — chasis genérico estilo tmux: hospeda N paneles en un árbol BSP (llimphi-widget-panes) con la máquina de estados (split/close/focus/resize) + chrome estándar. La capa sobre la que cualquier app de gioser se monta en un layout intercambiable."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panes = { path = "../widgets/panes" }
@@ -0,0 +1,212 @@
//! Demo del chasis `llimphi-workspace`.
//!
//! Mismo resultado que `panes_demo` pero la app ya no reimplementa la
//! máquina de estados: guarda un `Workspace` + un mapa de paneles, y deja
//! que el chasis maneje split/cerrar/foco/resize y el chrome. Esto es el
//! molde que después adopta cada app de gioser.
//!
//! Correr: `cargo run -p llimphi-workspace --example workspace_demo --release`
use std::collections::HashMap;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::{App, Handle, View};
use llimphi_theme::Theme;
use llimphi_workspace::{workspace_view, Axis, PaneId, Workspace, WorkspacePalette, WsEffect, WsMsg};
struct Demo;
#[derive(Clone)]
enum Msg {
Ws(WsMsg),
Panel(PaneId, PanelMsg),
}
#[derive(Clone)]
enum PanelMsg {
Inc,
Dec,
AddNote,
}
enum Kind {
Counter(i64),
Notes(Vec<String>),
}
struct Model {
ws: Workspace,
panes: HashMap<PaneId, Kind>,
theme: Theme,
}
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"workspace — chasis tmux de gioser"
}
fn init(_: &Handle<Msg>) -> Model {
let mut ws = Workspace::new(); // panel 0
let mut panes = HashMap::new();
panes.insert(0, Kind::Counter(0));
let id = ws.split(Axis::Horizontal);
panes.insert(id, Kind::Notes(vec!["arrastrá el divisor del medio →".into()]));
ws.focus(0);
Model {
ws,
panes,
theme: Theme::dark(),
}
}
fn update(mut model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
match msg {
Msg::Ws(m) => match model.ws.apply(m) {
WsEffect::Created(id) => {
// Alternamos tipo para ilustrar paneles heterogéneos.
let kind = if id % 2 == 0 {
Kind::Counter(0)
} else {
Kind::Notes(vec![])
};
model.panes.insert(id, kind);
}
WsEffect::Closed(id) => {
model.panes.remove(&id);
}
WsEffect::None => {}
},
Msg::Panel(id, pm) => {
if let Some(kind) = model.panes.get_mut(&id) {
match (kind, pm) {
(Kind::Counter(n), PanelMsg::Inc) => *n += 1,
(Kind::Counter(n), PanelMsg::Dec) => *n -= 1,
(Kind::Notes(v), PanelMsg::AddNote) => {
let n = v.len() + 1;
v.push(format!("nota #{n}"));
}
_ => {}
}
}
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let palette = WorkspacePalette::from_theme(&model.theme);
let panes = &model.panes;
let theme = &model.theme;
workspace_view(
&model.ws,
&palette,
move |id| render_pane(panes, theme, id),
Msg::Ws,
)
}
}
fn render_pane(panes: &HashMap<PaneId, Kind>, t: &Theme, id: PaneId) -> View<Msg> {
let Some(kind) = panes.get(&id) else {
return label("(vacío)".to_string(), 14.0, t.fg_muted);
};
let body = match kind {
Kind::Counter(n) => col(
8.0,
vec![
label(format!("{n}"), 44.0, t.accent),
row(
8.0,
vec![
button("", Msg::Panel(id, PanelMsg::Dec), t),
button("+", Msg::Panel(id, PanelMsg::Inc), t),
],
),
],
),
Kind::Notes(v) => {
let mut lines: Vec<View<Msg>> = v
.iter()
.map(|s| label(format!("{s}"), 14.0, t.fg_text))
.collect();
lines.push(button("+ nota", Msg::Panel(id, PanelMsg::AddNote), t));
col(6.0, lines)
}
};
View::new(Style {
flex_direction: FlexDirection::Column,
gap: Size {
width: length(10.0),
height: length(10.0),
},
padding: uniform(12.0),
flex_grow: 1.0,
..Default::default()
})
.children(vec![label(format!("panel #{id}"), 13.0, t.fg_muted), body])
}
fn button(text: &str, msg: Msg, t: &Theme) -> View<Msg> {
View::new(Style {
padding: Rect {
left: length(12.0),
right: length(12.0),
top: length(6.0),
bottom: length(6.0),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(t.bg_button)
.hover_fill(t.bg_button_hover)
.radius(6.0)
.on_click(msg)
.children(vec![label(text.to_string(), 14.0, t.fg_text)])
}
fn col(gap: f32, children: Vec<View<Msg>>) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Column,
gap: Size {
width: length(gap),
height: length(gap),
},
..Default::default()
})
.children(children)
}
fn row(gap: f32, children: Vec<View<Msg>>) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
gap: Size {
width: length(gap),
height: length(gap),
},
..Default::default()
})
.children(children)
}
fn label(text: String, size: f32, color: llimphi_ui::llimphi_raster::peniko::Color) -> View<Msg> {
View::new(Style::default()).text(text, size, color)
}
fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
Rect {
left: length(px),
right: length(px),
top: length(px),
bottom: length(px),
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+378
View File
@@ -0,0 +1,378 @@
//! `llimphi-workspace` — chasis genérico estilo tmux.
//!
//! Paso 2 de la visión "montar cualquier componente de gioser en un layout
//! intercambiable con splits resizables". Donde [`llimphi_widget_panes`]
//! aporta el **árbol** (estructura + render + drag), este crate aporta la
//! **máquina de estados** (qué panel está enfocado, cómo se parte/cierra,
//! el contador de ids) + el **chrome estándar** (toolbar split/cerrar).
//!
//! ## Cómo lo usa una app
//!
//! La app guarda un [`Workspace`] en su `Model` y un `HashMap<PaneId, …>`
//! con el estado de cada panel. Su `Msg` envuelve dos cosas:
//!
//! ```ignore
//! enum Msg {
//! Ws(WsMsg), // mensajes del chasis (focus/split/…)
//! Panel(PaneId, PanelMsg), // mensajes de un panel concreto
//! }
//! ```
//!
//! En `update`, los `Ws` se aplican con [`Workspace::apply`], que devuelve
//! un [`WsEffect`] indicando si hay que **crear** el estado de un panel
//! nuevo o **borrar** el de uno cerrado. En `view`, [`workspace_view`] arma
//! el chrome + el árbol; la app sólo provee el contenido de cada hoja (ya
//! lifteado a su propio `Msg` — el chasis no toca los `PanelMsg`).
//!
//! El lift se hace al construir la vista (igual que `shuma-module`), así
//! sorteamos la falta de `View::map` sin `Box<dyn Any>`: el chasis es
//! genérico sobre el `Msg` del host y nunca downcastea.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_widget_panes::{panes_view, Layout, PanesPalette};
pub use llimphi_widget_panes::{Axis, PaneId, Side};
/// Estado del workspace: el árbol de paneles + cuál está enfocado + el
/// contador para asignar ids nuevos. Agnóstico del contenido — el host
/// guarda el estado real de cada panel por su `PaneId`.
#[derive(Debug, Clone)]
pub struct Workspace {
layout: Layout,
focused: PaneId,
next_id: PaneId,
}
impl Workspace {
/// Workspace con un único panel (id `0`).
pub fn new() -> Self {
Self {
layout: Layout::single(0),
focused: 0,
next_id: 1,
}
}
/// Id del panel enfocado.
pub fn focused(&self) -> PaneId {
self.focused
}
/// Cantidad de paneles.
pub fn count(&self) -> usize {
self.layout.count()
}
/// Ids de todos los paneles, en orden espacial.
pub fn leaves(&self) -> Vec<PaneId> {
self.layout.leaves()
}
/// El árbol crudo (para casos avanzados; lo normal es [`workspace_view`]).
pub fn layout(&self) -> &Layout {
&self.layout
}
/// Enfoca un panel (no-op si no existe).
pub fn focus(&mut self, id: PaneId) {
if self.layout.contains(id) {
self.focused = id;
}
}
/// Parte el panel enfocado en `axis`; el nuevo queda enfocado. Devuelve
/// el `PaneId` nuevo para que el host cree su estado.
pub fn split(&mut self, axis: Axis) -> PaneId {
let id = self.next_id;
self.next_id += 1;
self.layout.split(self.focused, id, axis);
self.focused = id;
id
}
/// Cierra el panel enfocado (no cierra el último). Devuelve el id
/// removido para que el host libere su estado, o `None` si no removió.
pub fn close(&mut self) -> Option<PaneId> {
if self.count() <= 1 {
return None;
}
let target = self.focused;
let (nl, removed) = self.layout.clone().without(target);
if removed {
self.layout = nl;
self.focused = self.layout.first_leaf();
Some(target)
} else {
None
}
}
/// Ajusta el ratio del split direccionado por `path`.
pub fn resize(&mut self, path: &[Side], delta: f32) {
self.layout.resize(path, delta);
}
/// Aplica un mensaje del chasis y reporta el efecto a atender.
pub fn apply(&mut self, msg: WsMsg) -> WsEffect {
match msg {
WsMsg::Focus(id) => {
self.focus(id);
WsEffect::None
}
WsMsg::Split(axis) => WsEffect::Created(self.split(axis)),
WsMsg::Close => match self.close() {
Some(id) => WsEffect::Closed(id),
None => WsEffect::None,
},
WsMsg::Resize(path, d) => {
self.resize(&path, d);
WsEffect::None
}
}
}
}
impl Default for Workspace {
fn default() -> Self {
Self::new()
}
}
/// Mensajes del chasis. El host los envuelve en su propio `Msg` y los rutea
/// a [`Workspace::apply`].
#[derive(Debug, Clone, PartialEq)]
pub enum WsMsg {
Focus(PaneId),
Split(Axis),
Close,
Resize(Vec<Side>, f32),
}
/// Resultado de [`Workspace::apply`] — qué tiene que hacer el host con su
/// mapa de estados de panel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WsEffect {
/// Nada que hacer.
None,
/// Se creó un panel nuevo con este id: inicializá su estado.
Created(PaneId),
/// Se cerró este panel: borrá su estado.
Closed(PaneId),
}
/// Paleta del chasis.
#[derive(Debug, Clone, Copy)]
pub struct WorkspacePalette {
pub panes: PanesPalette,
pub bar_bg: Color,
pub btn_bg: Color,
pub btn_hover: Color,
pub label: Color,
pub muted: Color,
}
impl Default for WorkspacePalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl WorkspacePalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
panes: PanesPalette::from_theme(t),
bar_bg: t.bg_panel,
btn_bg: t.bg_button,
btn_hover: t.bg_button_hover,
label: t.fg_text,
muted: t.fg_muted,
}
}
}
/// Arma el chasis completo: toolbar (Split →/↓, Cerrar, estado) + el árbol
/// de paneles.
///
/// - `leaf` materializa el contenido de cada panel — **ya lifteado al `Msg`
/// del host** (el host hace el lift internamente con su `Panel(id, …)`).
/// - `lift` mapea los [`WsMsg`] del chasis al `Msg` del host.
pub fn workspace_view<Host>(
ws: &Workspace,
palette: &WorkspacePalette,
mut leaf: impl FnMut(PaneId) -> View<Host>,
lift: impl Fn(WsMsg) -> Host + Clone + Send + Sync + 'static,
) -> View<Host>
where
Host: Clone + Send + Sync + 'static,
{
let toolbar = View::new(Style {
flex_direction: FlexDirection::Row,
gap: Size {
width: length(8.0),
height: length(8.0),
},
padding: uniform(8.0),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bar_bg)
.children(vec![
button("Split →", lift(WsMsg::Split(Axis::Horizontal)), palette),
button("Split ↓", lift(WsMsg::Split(Axis::Vertical)), palette),
button("Cerrar", lift(WsMsg::Close), palette),
View::new(Style {
flex_grow: 1.0,
..Default::default()
}),
text(
format!("foco #{} · {} paneles", ws.focused(), ws.count()),
13.0,
palette.muted,
),
]);
let lift_resize = lift.clone();
let lift_focus = lift.clone();
let area = panes_view(
ws.layout(),
ws.focused(),
|id| leaf(id),
move |path, phase, d| {
let _ = phase;
Some((lift_resize)(WsMsg::Resize(path, d)))
},
move |id| (lift_focus)(WsMsg::Focus(id)),
&palette.panes,
);
let area_wrap = View::new(Style {
flex_grow: 1.0,
size: full(),
min_size: zero(),
..Default::default()
})
.children(vec![area]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: full(),
..Default::default()
})
.children(vec![toolbar, area_wrap])
}
fn button<Host>(label: &str, msg: Host, palette: &WorkspacePalette) -> View<Host>
where
Host: Clone + Send + Sync + 'static,
{
View::new(Style {
padding: Rect {
left: length(12.0),
right: length(12.0),
top: length(6.0),
bottom: length(6.0),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.btn_bg)
.hover_fill(palette.btn_hover)
.radius(6.0)
.on_click(msg)
.children(vec![text(label.to_string(), 14.0, palette.label)])
}
fn text<Host>(content: String, size: f32, color: Color) -> View<Host>
where
Host: Clone + Send + Sync + 'static,
{
View::new(Style::default()).text(content, size, color)
}
fn full() -> Size<Dimension> {
Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
}
}
fn zero() -> Size<Dimension> {
Size {
width: length(0.0_f32),
height: length(0.0_f32),
}
}
fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
Rect {
left: length(px),
right: length(px),
top: length(px),
bottom: length(px),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn starts_with_one_pane() {
let ws = Workspace::new();
assert_eq!(ws.count(), 1);
assert_eq!(ws.focused(), 0);
}
#[test]
fn split_creates_and_focuses_new() {
let mut ws = Workspace::new();
let id = ws.split(Axis::Horizontal);
assert_eq!(ws.count(), 2);
assert_eq!(ws.focused(), id);
assert_ne!(id, 0);
}
#[test]
fn apply_split_reports_created() {
let mut ws = Workspace::new();
match ws.apply(WsMsg::Split(Axis::Vertical)) {
WsEffect::Created(id) => assert_eq!(id, ws.focused()),
other => panic!("esperaba Created, fue {other:?}"),
}
}
#[test]
fn close_reports_closed_and_refocuses() {
let mut ws = Workspace::new();
let id = ws.split(Axis::Horizontal); // foco en el nuevo
match ws.apply(WsMsg::Close) {
WsEffect::Closed(closed) => {
assert_eq!(closed, id);
assert_eq!(ws.count(), 1);
assert_eq!(ws.focused(), 0);
}
other => panic!("esperaba Closed, fue {other:?}"),
}
}
#[test]
fn cannot_close_last_pane() {
let mut ws = Workspace::new();
assert_eq!(ws.apply(WsMsg::Close), WsEffect::None);
assert_eq!(ws.count(), 1);
}
#[test]
fn focus_ignores_unknown() {
let mut ws = Workspace::new();
ws.focus(999);
assert_eq!(ws.focused(), 0);
}
}