refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel

Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+263
View File
@@ -0,0 +1,263 @@
//! Filmstrip headless de **animaciones implícitas**, tres filas:
//!
//! - **Fila 1** — `View::animated`: el mismo nodo cuyo `fill` cambia de rojo a
//! azul, reconciliado a 6 instantes crecientes — crossfade rojo→púrpura→azul.
//! - **Fila 2** — `View::animated_enter`: el fade-in de ENTRADA de un nodo, de
//! opacidad 0 a opaco, a los mismos 6 progresos.
//! - **Fila 3** — `View::animated_exit`: el fade-out de SALIDA de un nodo
//! (capturado mientras vivía, reproducido como fantasma), de opaco a 0.
//!
//! Prueba el camino completo View.animated[_enter|_exit] → AnimRegistry →
//! paint/paint_range/replay_ghosts → píxeles.
//!
//! `cargo run -p llimphi-compositor --example anim_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use std::time::{Duration, Instant};
use llimphi_compositor::{mount, paint, paint_range, AnimRegistry, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy::prelude::{length, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
use vello::kurbo::Affine;
const W: u32 = 1180;
const H: u32 = 580;
/// Y de la fila de crossfade, de fade-in de entrada y de fade-out de salida.
const ROW_FADE_Y: f64 = 40.0;
const ROW_ENTER_Y: f64 = 220.0;
const ROW_EXIT_Y: f64 = 400.0;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const FRAMES: usize = 6;
const DUR: Duration = Duration::from_millis(500);
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
/// Una tarjeta animada (key=1) con `fill`, transladada (vía `transform`) a su
/// columna `i` y con el `fill` que la `view` "quiere" este frame.
fn card_shell(col: usize, row_y: f64, label: &str, fg: Color) -> View<()> {
View::<()>::new(Style {
size: Size { width: length(170.0), height: length(140.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
flex_direction: FlexDirection::Column,
..Default::default()
})
.transform(Affine::translate((20.0 + col as f64 * 190.0, row_y)))
.radius(18.0)
.children(vec![View::<()>::new(Style {
size: Size { width: length(150.0), height: length(20.0) },
..Default::default()
})
.text_aligned(label.to_string(), 13.0, fg, Alignment::Center)])
}
fn card(fill: Color, col: usize, label: &str, fg: Color) -> View<()> {
card_shell(col, ROW_FADE_Y, label, fg).fill(fill).animated(1, DUR)
}
/// Tarjeta con animación de ENTRADA: su primera aparición sube de opacidad 0
/// a opaco. La key se varía por columna (key=10+col) para que cada registro la
/// trate como una entrada nueva e independiente.
fn card_enter(col: usize, label: &str, fg: Color) -> View<()> {
card_shell(col, ROW_ENTER_Y, label, fg)
.fill(rgb(60, 90, 220))
.animated_enter(10 + col as u64, DUR)
}
/// Tarjeta con animación de SALIDA: cuando desaparece, el runtime reproduce su
/// subescena capturada con opacidad decreciente. Verde para distinguir la fila.
fn card_exit(col: usize, label: &str, fg: Color) -> View<()> {
card_shell(col, ROW_EXIT_Y, label, fg)
.fill(rgb(40, 160, 90))
.animated_exit(20 + col as u64, DUR)
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "anim.png".to_string());
let red = rgb(220, 60, 60);
let blue = rgb(60, 90, 220);
let white = rgb(245, 245, 250);
// Un registro por columna: cada uno se asienta en rojo y arranca el tween
// a azul en t0, pero se OBSERVA a un instante distinto (i * paso). Así el
// filmstrip muestra la misma transición a 6 progresos.
let t0 = Instant::now();
let step = DUR / (FRAMES as u32 - 1);
let mut cards = Vec::new();
for i in 0..FRAMES {
let mut reg = AnimRegistry::new();
// Frame de asentamiento (rojo) en t0.
{
let mut layout = LayoutTree::new();
let mut m = mount(&mut layout, card(red, i, "", white));
reg.reconcile(&mut m, t0);
}
// Frame de detección del cambio a azul (arranca el reloj en t0).
{
let mut layout = LayoutTree::new();
let mut m = mount(&mut layout, card(blue, i, "", white));
reg.reconcile(&mut m, t0);
}
// Frame de observación: el nodo `card` se reconcilia a t0 + i*paso y su
// `fill` queda con el valor interpolado. Lo dejamos para pintar.
let now = t0 + step * i as u32;
let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
let mut layout = LayoutTree::new();
let mut m = mount(&mut layout, card(blue, i, &format!("{pct}%"), white));
let computed = layout.compute(m.root, (W as f32, H as f32)).expect("layout");
reg.reconcile(&mut m, now);
cards.push((m, computed));
// Fila de entrada: la PRIMERA aparición ARRANCA el tween (en t0), así
// que el frame de observación a `now` ve el progreso correcto. Si se
// reconciliara una sola vez en `now`, el tween arrancaría y se
// observaría en el mismo instante → siempre t=0 (invisible).
let mut reg_enter = AnimRegistry::new();
{
let mut layout = LayoutTree::new();
let mut me = mount(&mut layout, card_enter(i, "", white));
reg_enter.reconcile(&mut me, t0);
}
let mut layout = LayoutTree::new();
let mut me = mount(&mut layout, card_enter(i, &format!("{pct}%"), white));
let computed = layout.compute(me.root, (W as f32, H as f32)).expect("layout");
reg_enter.reconcile(&mut me, now);
cards.push((me, computed));
}
// Fila de SALIDA: cada columna corre el ciclo real captura→fantasma→replay.
// (1) frame VIVO en t0: se reconcilia y se captura su subescena con
// `paint_range`. (2) frame AUSENTE en t0: la key desaparece → se promueve a
// fantasma (start=t0). (3) `replay_ghosts` a t0+paso·i lo pinta con la
// opacidad decreciente sobre `ghost_scene`, que luego se compone.
let mut ts_exit = Typesetter::new();
let mut ghost_scene = vello::Scene::new();
for i in 0..FRAMES {
let mut reg = AnimRegistry::new();
// (1) Vivo: reconcilia y captura su subárbol (root = idx 0).
{
let mut layout = LayoutTree::new();
let mut mv = mount(&mut layout, card_exit(i, "", white));
let computed = layout.compute(mv.root, (W as f32, H as f32)).expect("layout");
reg.reconcile(&mut mv, t0);
let n = mv.nodes.len();
let mut sub = vello::Scene::new();
paint_range(&mut sub, &mv, &computed, &mut ts_exit, None, None, 0, n, Affine::IDENTITY);
reg.store_live_exit(20 + i as u64, sub, DUR, llimphi_compositor::ease_out_cubic);
}
// (2) Ausente: la key se va → fantasma con start=t0.
{
let mut layout = LayoutTree::new();
let mut empty = mount(&mut layout, card_shell(i, ROW_EXIT_Y, "", white));
layout.compute(empty.root, (W as f32, H as f32)).expect("layout");
reg.reconcile(&mut empty, t0);
}
// (3) Observación: el fantasma se reproduce al progreso `now`.
let now = t0 + step * i as u32;
let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
reg.replay_ghosts(&mut ghost_scene, now, W as f32, H as f32);
// Rótulo del progreso sobre la tarjeta (fuera del fantasma, siempre nítido).
let mut layout = LayoutTree::new();
let mut lbl = mount(&mut layout, card_shell(i, ROW_EXIT_Y, &format!("{pct}%"), rgb(40, 50, 60)));
let lc = layout.compute(lbl.root, (W as f32, H as f32)).expect("layout");
// Sólo el texto (sin fill): reusa la tarjeta vacía como portador del label.
lbl.nodes[0].fill = None;
cards.push((lbl, lc));
}
// Pinta las columnas (cada una su árbol ya reconciliado) en una escena, y
// por debajo de los rótulos compone los fantasmas de la fila de salida.
let mut ts = Typesetter::new();
let mut scene = vello::Scene::new();
scene.append(&ghost_scene, None);
for (m, computed) in &cards {
paint(&mut scene, m, computed, &mut ts, None, None);
}
// Volcado a PNG.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-anim"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let bg = rgb(244, 245, 248);
renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"anim_demo: escrito {out} ({W}x{H}) — fila 1: crossfade rojo→azul · \
fila 2: fade-in de entrada · fila 3: fade-out de salida · {FRAMES} pasos"
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,279 @@
//! Filmstrip headless de **animateContentSize** (Bloque 15 de
//! PARIDAD-FLUTTER): un card con `View::animated_size(key, dur)`
//! arranca con tamaño 80×40 y, tras el primer frame, se reasigna a
//! 320×120. Renderizamos cinco frames simulando `Instant::now()` a
//! 0/60/120/180/240 ms — los del medio muestran el tween en curso, el
//! último ya está asentado.
//!
//! Verifica que el camino `reconcile_size_anim` parcha `style.size`
//! ANTES del mount/compute, así el layout cascade ve el tamaño
//! interpolado y los siblings reflowean (acá el padre es un row con
//! `gap`; el segundo hijo se va corriendo según crece el primero).
//!
//! `cargo run -p llimphi-compositor --example animated_size_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use std::time::{Duration, Instant};
use llimphi_compositor::{
measure_text_node, mount, paint, reconcile_size_anim, SizeAnimRegistry, View,
};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
const FRAME_W: u32 = 360;
const FRAME_H: u32 = 200;
const NUM_FRAMES: u32 = 5;
const W: u32 = FRAME_W * NUM_FRAMES;
const H: u32 = FRAME_H;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const KEY: u64 = 1;
const DUR_MS: u64 = 200;
const FRAME_STEP_MS: u64 = 60;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
/// Card animable cuya `target_size` se elige según el frame (frame 0 =
/// 80×40, resto = 320×120). El gap del row y el segundo hijo (un fixed
/// 60×40) garantizan que el sibling reflowee al crecer el card.
fn build_view(target_size: (f32, f32), accent: Color, fg: Color, panel: Color) -> View<()> {
let card = View::<()>::new(Style {
size: Size {
width: length(target_size.0),
height: length(target_size.1),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(accent)
.radius(12.0)
.text_aligned("animated", 14.0, panel, Alignment::Center)
.animated_size(KEY, Duration::from_millis(DUR_MS));
let companion = View::<()>::new(Style {
size: Size {
width: length(60.0_f32),
height: length(40.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(panel)
.radius(8.0)
.border(1.0, rgb(180, 184, 196))
.text_aligned("sib", 11.0, fg, Alignment::Center);
View::<()>::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
gap: Size {
width: length(12.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
..Default::default()
})
.fill(rgb(245, 247, 250))
.children(vec![card, companion])
}
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "animated_size.png".to_string());
let theme = llimphi_theme::Theme::light();
let accent = theme.accent;
let fg = Color::from_rgba8(30, 34, 44, 255);
let panel = theme.bg_panel;
let mut reg = SizeAnimRegistry::new();
let t0 = Instant::now();
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-animated-size"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view_tex = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
// Componemos UNA scene grande con los 5 frames lado-a-lado. Cada
// frame es un sub-tree de `View` posicionado con offset horizontal
// vía translate del paint — más simple: para cada sub-scene
// renderizamos a un buffer y lo blitteamos? Lo más directo: armamos
// un root flex Row de 5 frames con un divider mínimo.
let mut frames: Vec<View<()>> = Vec::with_capacity(NUM_FRAMES as usize);
let mut ts = Typesetter::new();
for i in 0..NUM_FRAMES {
// Target size: frame 0 = 80×40 (asentado); resto = 320×120 (target nuevo).
let target_size = if i == 0 { (80.0, 40.0) } else { (320.0, 120.0) };
let mut frame_view = build_view(target_size, accent, fg, panel);
let when = t0 + Duration::from_millis(i as u64 * FRAME_STEP_MS);
// Reconcilá el size en el árbol del frame. Después del frame 0
// el registry conoce target=80×40. En el frame 1 el target nuevo
// arranca el tween; los frames 2-4 lo continúan.
let animating = reconcile_size_anim(&mut frame_view, &mut reg, when);
// Pintamos cada frame en una columna fija dentro de un row root.
// El alto fijo + width fijo hace que el rect del frame esté
// delimitado; el contenido del frame ocupa todo el alto.
let frame_box = View::<()>::new(Style {
size: Size {
width: length(FRAME_W as f32),
height: length(FRAME_H as f32),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.fill(rgb(228, 232, 240))
.children(vec![
View::<()>::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
..Default::default()
})
.text_aligned(
format!(
"t = {} ms{}",
i as u64 * FRAME_STEP_MS,
if animating { " (animando)" } else { "" }
),
11.0,
fg,
Alignment::Center,
),
View::<()>::new(Style {
size: Size {
width: percent(1.0_f32),
height: length((FRAME_H - 20) as f32),
},
..Default::default()
})
.children(vec![frame_view]),
]);
frames.push(frame_box);
}
let root = View::<()>::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: length(W as f32),
height: length(H as f32),
},
..Default::default()
})
.children(frames);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
renderer
.render_to_view(&hal, &scene, &view_tex, W, H, bg)
.expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"animated_size_demo: escrito {out} ({W}x{H}) — 5 frames del card que \
crece de 80x40 a 320x120 en 200 ms. El sibling (cuadrado 'sib') se \
corre hacia la derecha por el gap del row a medida que el card crece.",
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,248 @@
//! Filmstrip headless del **backdrop blur** (Bloque 11 de PARIDAD-FLUTTER):
//! sobre un fondo con franjas de colores fuertes, una fila de cuatro paneles
//! `.backdrop_blur(σ)` con `σ ∈ {0, 4, 8, 16}` — el primero es la referencia
//! sin blur, el resto muestra el Gauss separable cada vez más fuerte.
//!
//! Prueba el camino `View::backdrop_blur` → `collect_backdrop_blurs` →
//! `BlurCompositor::blur` (post-pasada wgpu sobre la intermediate). Render
//! headless: vello pinta a una textura, el compositor de blur la modifica
//! in-place sobre los rects de cada panel, y volcamos a PNG.
//!
//! `cargo run -p llimphi-compositor --example backdrop_blur_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{collect_backdrop_blurs, mount, paint, View};
use llimphi_hal::{wgpu, BlurCompositor, Hal};
use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{Position, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::Typesetter;
const W: u32 = 1200;
const H: u32 = 360;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const SIGMAS: [f32; 4] = [0.0, 4.0, 8.0, 16.0];
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
Color::from_rgba8(r, g, b, a)
}
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "backdrop_blur.png".to_string());
// Fondo: cuatro franjas verticales saturadas — el blur tiene que mezclar
// los bordes entre franjas, así el efecto se ve aun sin texto/detalle.
let franjas: Vec<View<()>> = [
rgb(231, 76, 60),
rgb(241, 196, 15),
rgb(46, 204, 113),
rgb(52, 152, 219),
]
.iter()
.map(|c| {
View::<()>::new(Style {
size: Size {
width: percent(0.25),
height: percent(1.0),
},
..Default::default()
})
.fill(*c)
})
.collect();
let fondo = View::<()>::new(Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
right: auto(),
bottom: auto(),
},
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(franjas);
// Fila de paneles "vidrio": cada uno apunta a un σ distinto. Todos
// `Position::Absolute` con inset calculado para superponerse al fondo.
// El panel es un rect translúcido sin contenido propio (el blur post-
// pasada borronea TODO lo que está dentro del rect, así que un texto
// *dentro* del panel saldría borroso — limitación documentada del v1).
let panel_w = 240.0_f32;
let panel_h = 220.0_f32;
let gap = 24.0_f32;
let fila_w = SIGMAS.len() as f32 * panel_w + (SIGMAS.len() as f32 - 1.0) * gap;
let inicio_x = (W as f32 - fila_w) * 0.5;
let panel_y = (H as f32 - panel_h) * 0.5;
let mut hijos: Vec<View<()>> = vec![fondo];
for (i, &sigma) in SIGMAS.iter().enumerate() {
let x = inicio_x + i as f32 * (panel_w + gap);
let panel = View::<()>::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(panel_y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(panel_w),
height: length(panel_h),
},
..Default::default()
})
.fill(rgba(255, 255, 255, 96))
.radius(20.0)
.border(1.5, rgba(255, 255, 255, 180))
.backdrop_blur(sigma);
hijos.push(panel);
}
let root = View::<()>::new(Style {
size: Size {
width: length(W as f32),
height: length(H as f32),
},
..Default::default()
})
.children(hijos);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = layout
.compute(mounted.root, (W as f32, H as f32))
.expect("layout");
let mut ts = Typesetter::new();
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
// Render + post-pase de blur con BlurCompositor — el mismo camino que
// toma `llimphi-ui` durante un redraw real.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut blur = BlurCompositor::new(&hal.device);
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-blur"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &scene, &view, W, H, Color::BLACK)
.expect("render_to_view");
let blurs = collect_backdrop_blurs(&mounted, &computed);
let mut encoder = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("blur-demo-encoder"),
});
for b in &blurs {
blur.blur(
&hal.device,
&hal.queue,
&mut encoder,
&view,
(W, H),
b.rect,
b.sigma,
);
}
hal.queue.submit(std::iter::once(encoder.finish()));
write_png(&hal, &target, &out);
eprintln!(
"backdrop_blur_demo: escrito {out} ({W}x{H}) — {} paneles σ={:?}; \
σ=0 queda nítido (el compositor no-op'ea); los demás muestran el \
Gauss separable sobre las franjas. {} blur node(s) detectado(s).",
SIGMAS.len(),
SIGMAS,
blurs.len(),
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+281
View File
@@ -0,0 +1,281 @@
//! Filmstrip headless de la **familia `filter`** (Fases 7.12327.1235): sobre un
//! fondo a franjas, una fila de tiles iguales, cada uno con un `filter` distinto
//! — referencia, `blur`, `grayscale`, `invert`, `sepia` y `drop-shadow`.
//!
//! Ejercita el camino completo `View::filter` → `collect_filters` → post-pasada
//! GPU (`BlurCompositor` para `blur`, `ColorFilterCompositor` para las matrices
//! de color), más el `drop-shadow` que se pinta inline en vello (no es
//! post-pasada). Es el mismo orden que toma `llimphi-ui` en un redraw real.
//! Render headless: vello pinta a una textura, los compositores la modifican
//! in-place sobre el rect de cada tile, y volcamos a PNG.
//!
//! `cargo run -p llimphi-compositor --example filter_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{collect_filters, mount, paint, FilterOp, Shadow, View};
use llimphi_hal::{wgpu, BlurCompositor, ColorFilterCompositor, Hal};
use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{Position, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::Typesetter;
const W: u32 = 1320;
const H: u32 = 320;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
// Matriz identidad 4×5 (referencia, sin efecto).
const IDENTITY: [f32; 20] = [
1., 0., 0., 0., 0., //
0., 1., 0., 0., 0., //
0., 0., 1., 0., 0., //
0., 0., 0., 1., 0.,
];
// grayscale(1): luminancia Rec.709 en las tres filas.
const GRAYSCALE: [f32; 20] = [
0.2126, 0.7152, 0.0722, 0., 0., //
0.2126, 0.7152, 0.0722, 0., 0., //
0.2126, 0.7152, 0.0722, 0., 0., //
0., 0., 0., 1., 0.,
];
// invert(1): out = 1 - in.
const INVERT: [f32; 20] = [
-1., 0., 0., 0., 1., //
0., -1., 0., 0., 1., //
0., 0., -1., 0., 1., //
0., 0., 0., 1., 0.,
];
// sepia(1): matriz fija de la spec.
const SEPIA: [f32; 20] = [
0.393, 0.769, 0.189, 0., 0., //
0.349, 0.686, 0.168, 0., 0., //
0.272, 0.534, 0.131, 0., 0., //
0., 0., 0., 1., 0.,
];
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "filter.png".to_string());
// Etiqueta + FilterOp de cada tile. El primero es la referencia sin filtro.
let tiles: Vec<(&str, Option<FilterOp>)> = vec![
("ref", None),
("blur", Some(FilterOp::Blur(6.0))),
("grayscale", Some(FilterOp::ColorMatrix(GRAYSCALE))),
("invert", Some(FilterOp::ColorMatrix(INVERT))),
("sepia", Some(FilterOp::ColorMatrix(SEPIA))),
(
"drop-shadow",
Some(FilterOp::DropShadow(Shadow {
color: Color::from_rgba8(0, 0, 0, 160),
blur: 12.0,
dx: 8.0,
dy: 10.0,
spread: 0.0,
})),
),
];
let _ = IDENTITY; // referencia documentada arriba.
// Fondo a franjas para que el blur tenga bordes que mezclar.
let franjas: Vec<View<()>> = [
rgb(231, 76, 60),
rgb(241, 196, 15),
rgb(46, 204, 113),
rgb(52, 152, 219),
rgb(155, 89, 182),
]
.iter()
.map(|c| {
View::<()>::new(Style {
size: Size { width: percent(0.2), height: percent(1.0) },
..Default::default()
})
.fill(*c)
})
.collect();
let fondo = View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
position: Position::Absolute,
inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() },
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(franjas);
let tile_w = 180.0_f32;
let tile_h = 180.0_f32;
let gap = 24.0_f32;
let n = tiles.len() as f32;
let fila_w = n * tile_w + (n - 1.0) * gap;
let inicio_x = (W as f32 - fila_w) * 0.5;
let tile_y = (H as f32 - tile_h) * 0.5;
let mut hijos: Vec<View<()>> = vec![fondo];
for (i, (_, op)) in tiles.iter().enumerate() {
let x = inicio_x + i as f32 * (tile_w + gap);
// Cada tile: un rect blanco con un bloque interno multicolor, para que
// las matrices de color tengan algo que transformar.
let interno = View::<()>::new(Style {
size: Size { width: percent(0.7), height: percent(0.7) },
margin: Rect {
left: percent(0.15),
top: percent(0.15),
right: auto(),
bottom: auto(),
},
..Default::default()
})
.fill(rgb(255, 140, 0))
.radius(14.0);
let mut tile = View::<()>::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(tile_y),
right: auto(),
bottom: auto(),
},
size: Size { width: length(tile_w), height: length(tile_h) },
..Default::default()
})
.fill(rgb(245, 245, 245))
.radius(18.0)
.children(vec![interno]);
if let Some(op) = op {
tile = tile.filter(vec![op.clone()]);
}
hijos.push(tile);
}
let root = View::<()>::new(Style {
size: Size { width: length(W as f32), height: length(H as f32) },
..Default::default()
})
.children(hijos);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = layout
.compute(mounted.root, (W as f32, H as f32))
.expect("layout");
let mut ts = Typesetter::new();
let mut scene = vello::Scene::new();
// `paint` ya pinta los drop-shadow inline (no son post-pasada).
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut blur = BlurCompositor::new(&hal.device);
let mut color = ColorFilterCompositor::new(&hal.device);
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-filter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &scene, &view, W, H, Color::BLACK)
.expect("render_to_view");
// Post-pasadas de filtro: el mismo camino que `llimphi-ui::redraw`.
let passes = collect_filters(&mounted, &computed);
let mut encoder = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("filter-demo") });
for p in &passes {
match &p.op {
FilterOp::Blur(sigma) => {
blur.blur(&hal.device, &hal.queue, &mut encoder, &view, (W, H), p.rect, *sigma);
}
FilterOp::ColorMatrix(m) => {
color.apply(&hal.device, &hal.queue, &mut encoder, &view, (W, H), p.rect, *m);
}
FilterOp::DropShadow(_) => {} // ya pintado por vello en `paint`.
}
}
hal.queue.submit(std::iter::once(encoder.finish()));
write_png(&hal, &target, &out);
eprintln!(
"filter_demo: escrito {out} ({W}x{H}) — tiles {:?}; {} post-pasada(s) de \
filtro (blur+color), drop-shadow pintado inline. ref queda sin tocar.",
tiles.iter().map(|(l, _)| *l).collect::<Vec<_>>(),
passes.len(),
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,264 @@
//! Demo headless de los dos primitivos GPU nuevos de Llimphi:
//!
//! - **Primitivo A** — disco/círculo relleno con AA por SDF en el shader
//! (`GpuBatch::add_disc` / `add_ring` en `llimphi-raster`). Esta demo
//! pinta por GPU directo una grilla de *rects instanciados* + una grilla
//! de *discos AA* sobre la misma pasada `GpuBatch::flush`.
//! - **Primitivo B** — over-layer: una escena vello que se rasteriza
//! DESPUÉS del pase GPU y se compone con alpha encima (un disco vello
//! grande + el rótulo "OVER" que deben quedar SOBRE los rects/discos
//! GPU). Replica exactamente el orden que el eventloop de `llimphi-ui`
//! aplica para `View::paint_over`: `[vello base] → [GPU] → [vello over]`.
//!
//! No abre ventana: compone sobre una textura intermedia `Rgba8Unorm`
//! (misma mecánica que el frame real) y vuelca el resultado a PNG.
//!
//! `cargo run -p llimphi-compositor --example gpu_primitivos_demo --release -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_hal::{wgpu, Hal, OverlayCompositor};
use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
use llimphi_raster::peniko::{Color, Fill};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{draw_block, TextBlock, Typesetter};
use vello::kurbo::{Affine, Circle};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "gpu_primitivos_demo.png".to_string());
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let pipelines = GpuPipelines::new(&hal.device, FMT);
let overlay = OverlayCompositor::new(&hal.device);
// ── Textura intermedia (donde se compone todo) ──────────────────────
let inter = make_tex(
&hal.device,
wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
);
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
// ── (1) vello base: fondo + rótulo "base vello" ─────────────────────
// render_to_view limpia con base_color y escribe todos los píxeles.
let mut base = vello::Scene::new();
let mut ts = Typesetter::new();
draw_label(
&mut base,
&mut ts,
16.0,
24.0,
"base vello (fondo)",
18.0,
Color::from_rgba8(120, 130, 150, 255),
);
renderer
.render_to_view(
&hal,
&base,
&inter_view,
W,
H,
Color::from_rgba8(16, 20, 30, 255),
)
.expect("render base");
// ── (2) pase GPU directo: grilla de rects + grilla de discos AA ─────
// Un solo GpuBatch → un flush con LoadOp::Load (preserva el fondo vello).
let mut batch = GpuBatch::new(&pipelines);
// Grilla de rects instanciados (mitad izquierda).
for j in 0..6 {
for i in 0..6 {
let x = 40.0 + i as f32 * 46.0;
let y = 70.0 + j as f32 * 46.0;
let c = Color::from_rgba8(
60 + (i * 30) as u8,
90 + (j * 24) as u8,
200,
255,
);
batch.add_rect(x, y, 36.0, 36.0, c);
}
}
// Grilla de discos AA (mitad derecha) — radios variables para ver el
// suavizado del borde a distintas escalas.
for j in 0..6 {
for i in 0..6 {
let cx = 400.0 + i as f32 * 46.0;
let cy = 88.0 + j as f32 * 46.0;
let r = 8.0 + (i + j) as f32 * 1.4;
let c = Color::from_rgba8(
240,
120 + (i * 18) as u8,
60 + (j * 24) as u8,
255,
);
batch.add_disc(cx, cy, r, c);
}
}
// Un anillo grande para ejercitar add_ring (borde interno + externo AA).
batch.add_ring(180.0, 380.0, 46.0, 10.0, Color::from_rgba8(120, 240, 200, 255));
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("gpu-prim-pass"),
});
batch.flush(
&hal.device,
&hal.queue,
&mut enc,
&inter_view,
(W as f32, H as f32),
wgpu::LoadOp::Load,
);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
// ── (3) over-layer vello: disco grande + rótulo "OVER" ──────────────
// Se rasteriza en una scratch transparente y se compone con alpha
// sobre la intermedia DESPUÉS del pase GPU → queda ENCIMA de los
// rects/discos GPU. Espejo exacto del camino de redraw.rs.
let scratch = make_tex(
&hal.device,
wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let scratch_view = scratch.create_view(&wgpu::TextureViewDescriptor::default());
let mut over = vello::Scene::new();
// Disco vello grande y semitransparente que se monta SOBRE la grilla
// GPU (su centro cae sobre rects y discos a la vez).
over.fill(
Fill::NonZero,
Affine::IDENTITY,
Color::from_rgba8(255, 60, 120, 200),
None,
&Circle::new((300.0, 230.0), 70.0),
);
draw_label(
&mut over,
&mut ts,
232.0,
222.0,
"OVER",
30.0,
Color::from_rgba8(255, 255, 255, 255),
);
renderer
.render_to_view(&hal, &over, &scratch_view, W, H, Color::TRANSPARENT)
.expect("render over");
let mut enc2 = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("over-composite"),
});
overlay.composite(&hal.device, &mut enc2, &inter_view, &scratch_view);
hal.queue.submit(std::iter::once(enc2.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
// ── (4) readback → PNG ──────────────────────────────────────────────
write_png(&hal, &inter, &out);
eprintln!("gpu_primitivos_demo: escrito {out} ({W}x{H})");
}
fn make_tex(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("gpu-prim-tex"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage,
view_formats: &[],
})
}
fn draw_label(
scene: &mut vello::Scene,
ts: &mut Typesetter,
x: f32,
y: f32,
text: &str,
size: f32,
color: Color,
) {
// Reusa el typesetter: layout de una línea y blit de glyphs a la escena.
let block = TextBlock::simple(text, size, color, (x as f64, y as f64));
draw_block(scene, ts, &block);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,288 @@
//! Filmstrip headless de **`ImageFit`** (Bloque 12 de PARIDAD-FLUTTER):
//! una misma imagen 4:3 sintética se compone en cinco rects 1:1 con
//! `ImageFit::{Contain, Cover, Fill, None}` y, al final, un círculo
//! redondeado al máximo con `ImageFit::Cover` para verificar que el
//! clip respeta `radius` / `corner_radii` (caso avatar).
//!
//! Prueba el camino `View::image` + `View::image_fit` → `paint` (pasada
//! de `node.image_fit` y `node_rrect` para el clip). Render headless:
//! vello pinta a una textura y volcamos a PNG.
//!
//! `cargo run -p llimphi-compositor --example image_fit_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use std::sync::Arc;
use llimphi_compositor::{measure_text_node, mount, paint, ImageFit, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::{
Blob, Color, ImageAlphaType, ImageBrush as Image, ImageData, ImageFormat,
};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
const W: u32 = 1500;
const H: u32 = 380;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Imagen sintética 4:3 (`240×180`): cuadrícula de 4×3 con colores
/// distintos por celda + cruz central blanca. Permite "ver" la
/// diferencia entre los fits sin embeber un archivo (`Contain` deja
/// banda, `Cover` recorta, `Fill` deforma la cruz, `None` clippea las
/// celdas externas).
fn make_image() -> Image {
const IW: u32 = 240;
const IH: u32 = 180;
const COLS: u32 = 4;
const ROWS: u32 = 3;
let palette: [[u8; 3]; 12] = [
[231, 76, 60], [241, 196, 15], [46, 204, 113], [52, 152, 219],
[155, 89, 182], [26, 188, 156], [230, 126, 34], [149, 165, 166],
[192, 57, 43], [243, 156, 18], [22, 160, 133], [41, 128, 185],
];
let mut px: Vec<u8> = Vec::with_capacity((IW * IH * 4) as usize);
let cw = IW / COLS;
let ch = IH / ROWS;
for y in 0..IH {
for x in 0..IW {
let col = (x / cw).min(COLS - 1);
let row = (y / ch).min(ROWS - 1);
let idx = (row * COLS + col) as usize;
// Cruz central blanca, ~8 px de grosor — la deformación de
// `Fill` se hace evidente cuando los brazos cambian de razón.
let mid_x = (x as i32 - IW as i32 / 2).abs() <= 4;
let mid_y = (y as i32 - IH as i32 / 2).abs() <= 4;
let [r, g, b] = if mid_x || mid_y {
[255, 255, 255]
} else {
palette[idx]
};
px.extend_from_slice(&[r, g, b, 255]);
}
}
Image::new(ImageData {
data: Blob::new(Arc::new(px)),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: IW,
height: IH,
})
}
/// Una "ficha" con la imagen arriba (cuadrada de 200×200) + un rótulo
/// abajo con el nombre del fit. Cuerpo blanco con borde sutil.
fn ficha(img: &Image, fit: ImageFit, label: &str, panel: Color, fg: Color) -> View<()> {
let visor = View::<()>::new(Style {
size: Size { width: length(200.0_f32), height: length(200.0_f32) },
..Default::default()
})
.fill(Color::from_rgba8(30, 34, 44, 255)) // fondo gris para que `Contain` deje banda visible
.radius(8.0)
.border(1.0, Color::from_rgba8(60, 66, 80, 255))
.image(img.clone())
.image_fit(fit);
View::<()>::new(Style {
size: Size { width: length(220.0_f32), height: length(260.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
..Default::default()
})
.fill(panel)
.radius(14.0)
.border(1.0, Color::from_rgba8(220, 224, 232, 255))
.children(vec![
visor,
View::<()>::new(Style {
size: Size { width: percent(0.95_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(label.to_string(), 14.0, fg, Alignment::Center),
])
}
/// Ficha avatar: imagen rectangular 4:3 metida en un cuadrado con
/// radio máximo (= círculo) y `Cover`. Verifica que el clip respeta el
/// `node_rrect` (corona el caso que rompía antes del Bloque 12).
fn avatar(img: &Image, panel: Color, fg: Color) -> View<()> {
let crc = View::<()>::new(Style {
size: Size { width: length(200.0_f32), height: length(200.0_f32) },
..Default::default()
})
.fill(Color::from_rgba8(30, 34, 44, 255))
.radius(100.0) // círculo completo
.border(2.0, Color::from_rgba8(60, 66, 80, 255))
.image(img.clone())
.image_fit(ImageFit::Cover);
View::<()>::new(Style {
size: Size { width: length(220.0_f32), height: length(260.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
..Default::default()
})
.fill(panel)
.radius(14.0)
.border(1.0, Color::from_rgba8(220, 224, 232, 255))
.children(vec![
crc,
View::<()>::new(Style {
size: Size { width: percent(0.95_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned("Cover + radio".to_string(), 14.0, fg, Alignment::Center),
])
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "image_fit.png".to_string());
let theme = llimphi_theme::Theme::light();
let panel = theme.bg_panel;
let fg = Color::from_rgba8(30, 34, 44, 255);
let img = make_image();
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(20.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(24.0_f32),
bottom: length(24.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.children(vec![
ficha(&img, ImageFit::Contain, "Contain", panel, fg),
ficha(&img, ImageFit::Cover, "Cover", panel, fg),
ficha(&img, ImageFit::Fill, "Fill", panel, fg),
ficha(&img, ImageFit::None, "None", panel, fg),
avatar(&img, panel, fg),
]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-image-fit"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer
.render_to_view(&hal, &scene, &view, W, H, bg)
.expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"image_fit_demo: escrito {out} ({W}x{H}) — 5 fichas: Contain (deja \
banda en el eje extra) · Cover (recorta el sobrante) · Fill (deforma \
la cruz) · None (1:1 centrada, recorta lo que no entra) · Cover sobre \
un cuadrado con radius=100 (avatar circular)."
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,243 @@
//! Demo headless del **LayoutBuilder** (Bloque 9 de PARIDAD-FLUTTER): el MISMO
//! árbol declarativo, renderizado a dos anchos de viewport. Un panel central
//! usa `View::layout_builder`: si su slot es **angosto** apila las tarjetas en
//! **1 columna**; si es **ancho**, en **2 columnas**. La decisión depende del
//! tamaño del slot (no de la ventana), resuelto en dos pasadas — exactamente lo
//! que el runtime hace por frame.
//!
//! Emula el camino del runtime (`resolve_layout_builders`) con las funciones
//! puras del compositor: `has_layout_builder` → mount pasada 1 → compute →
//! `collect_builder_constraints` → `expand_layout_builders` → mount/paint.
//!
//! Vuelca dos PNGs (`<base>-angosto.png` y `<base>-ancho.png`).
//! `cargo run -p llimphi-compositor --example layout_builder_demo -- [base]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{
collect_builder_constraints, expand_layout_builders, has_layout_builder, mount, paint,
Constraints, View,
};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, LengthPercentage, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
const H: u32 = 360;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Bajo este ancho de slot, el panel apila en 1 columna; por encima, 2.
const BREAKPOINT: f32 = 360.0;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
/// Una tarjeta de muestra.
fn card(label: &str) -> View<()> {
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(64.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(rgb(60, 72, 100))
.radius(10.0)
.children(vec![View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(20.0) },
..Default::default()
})
.text_aligned(label.to_string(), 14.0, rgb(235, 238, 245), Alignment::Center)])
}
/// El subárbol que el builder produce según sus constraints: 1 columna si
/// angosto, 2 si ancho. Cada columna es un flex column con tarjetas.
fn responsive_panel(c: Constraints) -> View<()> {
let dos_columnas = c.max_width >= BREAKPOINT;
let etiqueta = if dos_columnas {
format!("slot {:.0}px = 2 columnas", c.max_width)
} else {
format!("slot {:.0}px = 1 columna", c.max_width)
};
let header = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(28.0) },
..Default::default()
})
.text_aligned(etiqueta, 13.0, rgb(150, 200, 160), Alignment::Center);
let col = |labels: &[&str]| {
View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(10.0) },
..Default::default()
})
.children(labels.iter().map(|l| card(l)).collect())
};
let cuerpo = if dos_columnas {
View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
flex_direction: FlexDirection::Row,
gap: Size { width: length(12.0), height: length(0.0) },
..Default::default()
})
.children(vec![col(&["Uno", "Tres"]), col(&["Dos", "Cuatro"])])
} else {
col(&["Uno", "Dos", "Tres", "Cuatro"])
};
View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(8.0) },
..Default::default()
})
.children(vec![header, cuerpo])
}
/// Árbol raíz: una sidebar fija + un panel central que es el `layout_builder`.
/// El ancho del slot del panel = viewport sidebar paddings, así cambia con
/// el viewport sin que el árbol "sepa" el tamaño al construirse.
fn root() -> View<()> {
let sidebar = View::<()>::new(Style {
size: Size { width: length(160.0), height: percent(1.0) },
..Default::default()
})
.fill(rgb(34, 40, 54))
.radius(12.0)
.children(vec![View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(20.0) },
..Default::default()
})
.text_aligned("sidebar", 13.0, rgb(140, 150, 170), Alignment::Center)]);
let panel = View::<()>::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0), height: percent(1.0) },
..Default::default()
})
.layout_builder(responsive_panel);
View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
flex_direction: FlexDirection::Row,
gap: Size { width: length(16.0), height: length(0.0) },
padding: Rect {
left: LengthPercentage::length(16.0),
right: LengthPercentage::length(16.0),
top: LengthPercentage::length(16.0),
bottom: LengthPercentage::length(16.0),
},
..Default::default()
})
.fill(rgb(24, 28, 38))
.children(vec![sidebar, panel])
}
/// Resuelve los builders (dos pasadas) y vuelca el árbol a un PNG a ese ancho.
fn render_a(ancho: u32, ts: &mut Typesetter, hal: &Hal, renderer: &mut Renderer, path: &str) {
let viewport = (ancho as f32, H as f32);
// Pasada 1: montar (builders como hojas) + computar.
let v1 = root();
assert!(has_layout_builder(&v1), "el demo debe tener un layout_builder");
let mut l1 = LayoutTree::new();
let m1 = mount(&mut l1, v1);
let c1 = l1.compute(m1.root, viewport).expect("layout p1");
let cons = collect_builder_constraints(&m1, &c1);
// Pasada 2: árbol fresco + expand con las constraints reales.
let resolved = expand_layout_builders(root(), &cons);
let mut l2 = LayoutTree::new();
let m2 = mount(&mut l2, resolved);
let c2 = l2.compute(m2.root, viewport).expect("layout p2");
let mut scene = vello::Scene::new();
paint(&mut scene, &m2, &c2, ts, None, None);
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-lb"),
size: wgpu::Extent3d { width: ancho, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(hal, &scene, &view, ancho, H, rgb(244, 245, 248))
.expect("render_to_view");
write_png(hal, &target, ancho, path);
eprintln!("layout_builder_demo: escrito {path} ({ancho}x{H}) — slot panel {:.0}px", cons[0].max_width);
}
fn main() {
let base = std::env::args().nth(1).unwrap_or_else(|| "lb".to_string());
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut ts = Typesetter::new();
// Angosto: viewport 460 → slot ~268px (<360) → 1 columna.
render_a(460, &mut ts, &hal, &mut renderer, &format!("{base}-angosto.png"));
// Ancho: viewport 760 → slot ~568px (≥360) → 2 columnas.
render_a(760, &mut ts, &hal, &mut renderer, &format!("{base}-ancho.png"));
}
fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, path: &str) {
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: w, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), w, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wr = enc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,697 @@
//! Pantallazo headless del motor — **una UI real y densa** compuesta sólo con
//! primitivas del compositor (View → layout → vello → wgpu → PNG), pensada
//! para la tarjeta pública "Un motor gráfico soberano".
//!
//! Muestra en una sola pasada: tema oscuro de `llimphi-theme`, top bar con
//! tabs, sidebar con filas seleccionadas, un editor de código con resaltado
//! sintáctico vía `TextSpan`s sobre fuente mono, un párrafo de texto rico
//! (pesos, cursiva, subrayado, mono inline), tarjetas de métricas con
//! gradientes y sombras, un mini gráfico de barras hecho con puros rects,
//! chips/botones, y un toast flotante con esquinas asimétricas.
//!
//! `cargo run -p llimphi-compositor --example pantallazo_motor --release -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{measure_text_node, mount, paint, Shadow, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Position, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::{Color, Gradient};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, TextSpan, TextSpanStyle, Typesetter};
use vello::kurbo::Point;
const W: u32 = 1280;
const H: u32 = 800;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
Color::from_rgba8(r, g, b, a)
}
/// Caja vacía con tamaño fijo — separadores, swatches, barras.
fn rect(w: f32, h: f32) -> View<()> {
View::<()>::new(Style { size: Size { width: length(w), height: length(h) }, ..Default::default() })
}
/// Nodo de texto de una línea con alto fijo (mismo patrón que los demos vecinos).
fn txt(w: taffy::Dimension, h: f32, s: &str, size: f32, c: Color) -> View<()> {
View::<()>::new(Style { size: Size { width: w, height: length(h) }, ..Default::default() })
.text_aligned(s.to_string(), size, c, Alignment::Start)
}
/// Spans sintácticos: pinta TODAS las ocurrencias de cada `needle` con su estilo.
fn spans_all(text: &str, reglas: &[(&str, TextSpanStyle)]) -> Vec<TextSpan> {
let mut out = Vec::new();
for (needle, style) in reglas {
for (i, _) in text.match_indices(needle) {
out.push(TextSpan::new(i, i + needle.len(), style.clone()));
}
}
out
}
fn color_span(c: Color) -> TextSpanStyle {
TextSpanStyle { color: Some(c), ..Default::default() }
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "pantallazo_motor.png".to_string());
// Default dark (el look histórico de esta tarjeta); `LLIMPHI_THEME=<nombre>`
// fuerza otro preset por nombre canónico (p. ej. `Tawa`) para evidenciar la
// paleta firma sin alterar el default del pipeline público.
let theme = std::env::var("LLIMPHI_THEME")
.ok()
.and_then(|n| llimphi_theme::Theme::by_name(&n))
.unwrap_or_else(llimphi_theme::Theme::dark);
// Paleta de sintaxis (sobre el panel oscuro del theme).
let kw = rgb(198, 120, 221); // keywords — violeta
let ty = rgb(229, 192, 123); // tipos — ámbar
let fnc = rgb(97, 175, 239); // funciones — azul
let strv = rgb(152, 195, 121); // strings — verde
let cmt = rgb(92, 104, 124); // comentarios — gris azulado
let lit = rgb(209, 154, 102); // literales numéricos — naranja
let code_fg = rgb(171, 178, 191);
// ───────────────────────────── top bar ─────────────────────────────
let tab = |name: &str, activo: bool| {
let base = View::<()>::new(Style {
size: Size { width: auto(), height: length(30.0) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.radius(8.0);
let fg = if activo { theme.fg_text } else { theme.fg_muted };
let v = if activo { base.fill(theme.bg_selected) } else { base };
v.children(vec![txt(auto(), 18.0, name, 13.0, fg)])
};
let brand_dot = rect(10.0, 10.0)
.radius(5.0)
.fill_gradient(
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
.with_stops([theme.accent, rgb(80, 200, 200)].as_slice()),
);
let buscador = View::<()>::new(Style {
size: Size { width: length(230.0), height: length(28.0) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(12.0), right: length(12.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_input)
.radius(14.0)
.border(1.0, theme.border)
.children(vec![txt(auto(), 16.0, "buscar en el haz… ⌘K", 12.0, theme.fg_placeholder)]);
let topbar = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(46.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(10.0), height: length(0.0) },
padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_panel_alt)
.border(1.0, theme.border)
.children(vec![
brand_dot,
txt(auto(), 18.0, "llimphi", 15.0, theme.fg_text).bold(),
rect(14.0, 1.0),
tab("pluma", true),
tab("khipu", false),
tab("cosmos", false),
tab("shuma", false),
// empuja el buscador a la derecha
View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
buscador,
]);
// ───────────────────────────── sidebar ─────────────────────────────
let fila = |nombre: &str, badge: Option<&str>, sel: bool, dot: Color| {
let base = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(30.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
padding: Rect { left: length(10.0), right: length(10.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.radius(7.0);
let v = if sel { base.fill(theme.bg_selected) } else { base };
let izq = View::<()>::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(8.0), height: length(0.0) },
..Default::default()
})
.children(vec![
rect(8.0, 8.0).radius(2.5).fill(dot),
txt(length(135.0), 17.0, nombre, 13.0, if sel { theme.fg_text } else { theme.fg_muted }),
]);
let mut hijos = vec![izq];
if let Some(b) = badge {
hijos.push(
View::<()>::new(Style {
size: Size { width: auto(), height: length(17.0) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(7.0), right: length(7.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_button)
.radius(8.5)
.children(vec![txt(auto(), 13.0, b, 10.5, theme.fg_muted)]),
);
}
v.children(hijos)
};
let seccion = |t: &str| txt(percent(1.0), 16.0, t, 11.0, theme.fg_placeholder).bold();
let sidebar = View::<()>::new(Style {
size: Size { width: length(236.0), height: percent(1.0) },
flex_shrink: 0.0,
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(4.0) },
padding: Rect { left: length(12.0), right: length(12.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.fill(theme.bg_panel)
.border(1.0, theme.border)
.children(vec![
seccion("HAZ DE CUERPOS"),
fila("ensayo · español", None, true, theme.accent),
fila("ensayo · english", Some("stale"), false, rgb(209, 154, 102)),
fila("ensayo · runasimi", None, false, rgb(80, 200, 200)),
fila("resumen ejecutivo", None, false, rgb(152, 195, 121)),
rect(1.0, 10.0),
seccion("MÓDULOS"),
fila("nodegraph.rs", Some("12"), false, fnc),
fila("text_editor.rs", Some("3"), false, fnc),
fila("typesetter.rs", None, false, fnc),
fila("raster/scene.rs", None, false, fnc),
rect(1.0, 10.0),
seccion("DAEMONS"),
fila("verbo · e5-small", Some("384d"), false, rgb(152, 195, 121)),
fila("chasqui · DHT", Some("9"), false, rgb(152, 195, 121)),
]);
// ─────────────────────── editor de código (centro) ───────────────────────
// (líneas unidas a mano — la continuación `\` de Rust se comería la indentación)
let codigo = [
"// bucle Elm del motor: input → update → view → layout → raster",
"pub fn frame(&mut self, msg: Msg) -> Scene {",
" self.app.update(msg);",
" let view = self.app.view();",
" let tree = mount(&mut self.layout, view);",
" let computed = self.layout.compute(tree.root, self.size);",
" let mut scene = Scene::new();",
" paint(&mut scene, &tree, &computed, &mut self.ts);",
" scene // vello la rasteriza en GPU vía wgpu",
"}",
"",
"let sombra = Shadow::soft(90, 24.0).offset(0.0, 12.0);",
"let card = View::new(estilo)",
" .fill_gradient(grad) // gradiente en [0,1]²",
" .radius_corners(18.0, 18.0, 4.0, 4.0)",
" .shadow(sombra);",
]
.join("\n");
let codigo = codigo.as_str();
let reglas = [
("// bucle Elm del motor: input → update → view → layout → raster", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
("// vello la rasteriza en GPU vía wgpu", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
("// gradiente en [0,1]²", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
("pub fn ", color_span(kw)),
("let ", color_span(kw)),
("&mut ", color_span(kw)),
("self", color_span(rgb(224, 108, 117))),
("Msg", color_span(ty)),
("Scene", color_span(ty)),
("Shadow", color_span(ty)),
("View", color_span(ty)),
("frame", TextSpanStyle { color: Some(fnc), weight: Some(700.0), ..Default::default() }),
("update", color_span(fnc)),
("view()", color_span(fnc)),
("mount", color_span(fnc)),
("compute", color_span(fnc)),
("new", color_span(fnc)),
("paint", color_span(fnc)),
("soft", color_span(fnc)),
("offset", color_span(fnc)),
("fill_gradient", color_span(fnc)),
("radius_corners", color_span(fnc)),
("shadow(", color_span(fnc)),
("90, 24.0", color_span(lit)),
("0.0, 12.0", color_span(lit)),
("18.0, 18.0, 4.0, 4.0", color_span(lit)),
];
let code_spans = spans_all(codigo, &reglas);
let code_text = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(360.0) },
..Default::default()
})
.text_spans(codigo, 13.5, code_fg, code_spans, Alignment::Start)
.mono()
.line_height(1.55);
// header del editor: tres puntos + nombre de archivo + chip de lenguaje
let punto = |c: Color| rect(11.0, 11.0).radius(5.5).fill(c);
let editor_header = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(36.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(8.0), height: length(0.0) },
padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_panel_alt)
.radius_corners(12.0, 12.0, 0.0, 0.0)
.children(vec![
punto(rgb(224, 108, 117)),
punto(rgb(229, 192, 123)),
punto(rgb(152, 195, 121)),
rect(6.0, 1.0),
txt(auto(), 17.0, "eventloop.rs", 13.0, theme.fg_text).mono(),
txt(length(96.0), 16.0, "— llimphi-ui", 12.0, theme.fg_placeholder),
View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
txt(auto(), 16.0, "rust", 11.0, theme.accent).mono(),
]);
let editor = View::<()>::new(Style {
size: Size { width: percent(1.0), height: auto() },
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
..Default::default()
})
.fill(theme.bg_panel)
.radius(12.0)
.border(1.0, theme.border)
.shadow(Shadow::soft(110, 26.0).offset(0.0, 12.0))
.children(vec![
editor_header,
View::<()>::new(Style {
size: Size { width: percent(1.0), height: auto() },
flex_grow: 1.0,
padding: Rect { left: length(16.0), right: length(16.0), top: length(12.0), bottom: length(8.0) },
..Default::default()
})
.children(vec![code_text]),
]);
// ─────────────────── párrafo rico (debajo del editor) ───────────────────
let parrafo = "Un solo nodo de texto, varios lentes por rango de bytes: \
NEGRITA para el énfasis, cursiva para la voz, un enlace.qu subrayado, \
texto tachado para lo descartado, y Typesetter en mono inline — todo \
medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
let find = |n: &str| {
let i = parrafo.find(n).expect("needle");
(i, i + n.len())
};
let (b0, b1) = find("NEGRITA");
let (i0, i1) = find("cursiva");
let (l0, l1) = find("enlace.qu");
let (t0, t1) = find("tachado");
let (m0, m1) = find("Typesetter");
let rich_spans = vec![
TextSpan::new(b0, b1, TextSpanStyle { weight: Some(700.0), color: Some(theme.fg_text), ..Default::default() }),
TextSpan::new(i0, i1, TextSpanStyle { italic: Some(true), color: Some(theme.fg_text), ..Default::default() }),
TextSpan::new(l0, l1, TextSpanStyle { color: Some(theme.accent), underline: Some(true), ..Default::default() }),
TextSpan::new(t0, t1, TextSpanStyle { color: Some(theme.fg_destructive), strikethrough: Some(true), ..Default::default() }),
TextSpan::new(m0, m1, TextSpanStyle { font_family: Some(llimphi_text::MONOSPACE.to_string()), color: Some(rgb(80, 200, 200)), ..Default::default() }),
];
let rich_card = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(150.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(8.0) },
padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.fill(theme.bg_panel)
.radius(12.0)
.border(1.0, theme.border)
.children(vec![
txt(percent(1.0), 20.0, "Texto rico — spans nativos", 14.5, theme.fg_text).bold(),
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(78.0) },
..Default::default()
})
.text_spans(parrafo, 13.5, theme.fg_muted, rich_spans, Alignment::Start)
.line_height(1.45),
]);
let centro = View::<()>::new(Style {
size: Size { width: auto(), height: percent(1.0) },
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(14.0) },
padding: Rect { left: length(14.0), right: length(14.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.children(vec![editor, rich_card]);
// ───────────────────────── columna derecha ─────────────────────────
// 1) Tarjeta de métricas con gradiente + sombra (el look "hero card").
let grad_hero = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
.with_stops([rgb(64, 92, 180), rgb(34, 46, 96)].as_slice());
let metrica = |valor: &str, label: &str| {
View::<()>::new(Style {
size: Size { width: length(80.0), height: auto() },
flex_shrink: 0.0,
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(2.0) },
..Default::default()
})
.children(vec![
txt(length(80.0), 26.0, valor, 21.0, rgb(240, 244, 252)).bold(),
txt(length(80.0), 15.0, label, 11.0, rgba(214, 222, 240, 190)),
])
};
let hero = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(120.0) },
flex_direction: FlexDirection::Column,
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0), height: length(10.0) },
padding: Rect { left: length(18.0), right: length(18.0), top: length(12.0), bottom: length(12.0) },
..Default::default()
})
.fill_gradient(grad_hero)
.radius(14.0)
.border(1.0, rgba(150, 175, 240, 120))
.shadow(Shadow::soft(120, 28.0).offset(0.0, 14.0))
.children(vec![
txt(percent(1.0), 16.0, "RENDER · ÚLTIMO FRAME", 11.0, rgba(214, 222, 240, 200)).bold(),
View::<()>::new(Style {
flex_direction: FlexDirection::Row,
gap: Size { width: length(16.0), height: length(0.0) },
..Default::default()
})
.children(vec![metrica("1.8 ms", "scene → GPU"), metrica("2 411", "nodos"), metrica("60 fps", "vsync")]),
]);
// 2) Mini gráfico de barras: puros rects con gradiente, alineados al piso.
let alturas = [34.0_f32, 52.0, 41.0, 66.0, 58.0, 78.0, 49.0, 88.0, 71.0, 60.0, 94.0, 80.0];
let barras: Vec<View<()>> = alturas
.iter()
.enumerate()
.map(|(i, &h)| {
let g = if i == 10 {
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
.with_stops([rgb(120, 220, 200), rgb(60, 150, 140)].as_slice())
} else {
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
.with_stops([rgb(110, 140, 220), rgb(58, 78, 128)].as_slice())
};
rect(15.0, h).radius_corners(4.0, 4.0, 0.0, 0.0).fill_gradient(g)
})
.collect();
let chart = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(168.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(10.0) },
padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.fill(theme.bg_panel)
.radius(12.0)
.border(1.0, theme.border)
.children(vec![
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(18.0) },
flex_direction: FlexDirection::Row,
justify_content: Some(JustifyContent::SpaceBetween),
..Default::default()
})
.children(vec![
txt(length(200.0), 18.0, "Throughput del raster", 13.0, theme.fg_text).bold(),
View::<()>::new(Style {
size: Size { width: length(70.0), height: length(16.0) },
..Default::default()
})
.text_aligned("12 frames".to_string(), 11.0, theme.fg_placeholder, Alignment::End),
]),
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(94.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::FlexEnd),
justify_content: Some(JustifyContent::SpaceBetween),
..Default::default()
})
.children(barras),
]);
// 3) Botones / chips — el acento del theme en acción.
let boton = |label: &str, primario: bool| {
let base = View::<()>::new(Style {
size: Size { width: auto(), height: length(34.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect { left: length(18.0), right: length(18.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.radius(17.0);
if primario {
base.fill_gradient(
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
.with_stops([rgb(124, 154, 232), rgb(92, 120, 198)].as_slice()),
)
.shadow(Shadow::soft(90, 16.0).offset(0.0, 6.0))
.children(vec![txt(auto(), 18.0, label, 13.0, rgb(244, 247, 255)).bold()])
} else {
base.fill(theme.bg_button)
.border(1.0, theme.border)
.children(vec![txt(auto(), 18.0, label, 13.0, theme.fg_text)])
}
};
let acciones = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(34.0) },
flex_direction: FlexDirection::Row,
gap: Size { width: length(10.0), height: length(0.0) },
..Default::default()
})
.children(vec![boton("Regenerar", true), boton("Difundir", false), boton("Alinear", false)]);
// 4) Tarjeta de hebras (estado del haz multilienzo) — filas con dot de estado.
let hebra = |de: &str, a: &str, estado: &str, c: Color| {
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(26.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
..Default::default()
})
.children(vec![
View::<()>::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(8.0), height: length(0.0) },
..Default::default()
})
.children(vec![
rect(7.0, 7.0).radius(3.5).fill(c),
txt(length(170.0), 16.0, &format!("{de}{a}"), 12.0, theme.fg_text),
]),
View::<()>::new(Style {
size: Size { width: length(80.0), height: length(15.0) },
..Default::default()
})
.text_aligned(estado.to_string(), 11.0, c, Alignment::End),
])
};
let hebras = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(148.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(6.0) },
padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.fill(theme.bg_panel)
.radius(12.0)
.border(1.0, theme.border)
.children(vec![
txt(percent(1.0), 18.0, "Hebras del haz", 13.0, theme.fg_text).bold(),
hebra("español", "english", "stale", rgb(229, 192, 123)),
hebra("español", "runasimi", "al día", rgb(152, 195, 121)),
hebra("español", "resumen", "al día", rgb(152, 195, 121)),
hebra("english", "tono formal", "derivando…", theme.accent),
]);
let derecha = View::<()>::new(Style {
size: Size { width: length(330.0), height: percent(1.0) },
flex_shrink: 0.0,
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(14.0) },
padding: Rect { left: length(0.0), right: length(14.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.children(vec![hero, chart, acciones, hebras]);
// ───────────────────────── status bar ─────────────────────────
let status_item = |w: f32, s: &str, c: Color| txt(length(w), 16.0, s, 11.5, c);
let statusbar = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(30.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(18.0), height: length(0.0) },
padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_panel_alt)
.border(1.0, theme.border)
.children(vec![
status_item(70.0, "git · main", theme.accent),
status_item(250.0, "wgpu 27 · vello 0.7 · taffy · parley", theme.fg_muted),
status_item(80.0, "BLAKE3 ok", rgb(152, 195, 121)),
View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
status_item(45.0, "UTF-8", theme.fg_placeholder),
status_item(85.0, "Ln 7, Col 23", theme.fg_placeholder),
status_item(50.0, "100 Hz", theme.fg_placeholder),
]);
// ─────────────── toast flotante (absoluto, esquinas asimétricas) ───────────────
let toast = View::<()>::new(Style {
position: Position::Absolute,
inset: Rect { left: auto(), top: auto(), right: length(360.0), bottom: length(48.0) },
size: Size { width: length(290.0), height: length(64.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(12.0), height: length(0.0) },
padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(rgb(28, 34, 48))
.radius_corners(18.0, 18.0, 18.0, 4.0)
.border(1.0, rgba(110, 140, 220, 160))
.shadow(Shadow::soft(150, 30.0).offset(0.0, 14.0))
.children(vec![
rect(34.0, 34.0)
.radius(10.0)
.fill_gradient(
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
.with_stops([rgb(120, 220, 200), rgb(60, 140, 170)].as_slice()),
),
View::<()>::new(Style {
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(2.0) },
..Default::default()
})
.children(vec![
txt(auto(), 17.0, "Cuerpo regenerado", 13.0, theme.fg_text).bold(),
txt(auto(), 15.0, "english · 42 átomos realineados", 11.5, theme.fg_muted),
]),
]);
// ───────────────────────── árbol raíz ─────────────────────────
let fila_central = View::<()>::new(Style {
size: Size { width: percent(1.0), height: auto() },
flex_grow: 1.0,
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(vec![sidebar, centro, derecha]);
let root = View::<()>::new(Style {
size: Size { width: length(W as f32), height: length(H as f32) },
flex_direction: FlexDirection::Column,
..Default::default()
})
.fill(theme.bg_app)
.children(vec![topbar, fila_central, statusbar, toast]);
// view → layout → scene → render headless → PNG (misma secuencia que el eventloop).
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("pantallazo-motor"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!("pantallazo_motor: escrito {out} ({W}x{H})");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,273 @@
//! Volcado headless de las primitivas nuevas del compositor (Tier 1+2 del
//! roadmap PARIDAD-FLUTTER): **sombra · gradiente · borde · peso de fuente ·
//! radio por esquina**. Monta un árbol `View` con tarjetas que ejercitan cada
//! una (y su combinación), lo pinta a una `vello::Scene` y lee la textura a
//! PNG. Sirve para VERLAS sin ventana.
//!
//! `cargo run -p llimphi-compositor --example primitivas_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{measure_text_node, mount, paint, Shadow, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::{Color, Gradient};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
use vello::kurbo::Point;
const W: u32 = 1476;
const H: u32 = 340;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Una tarjeta con título + descripción, dimensionada igual para todas.
fn card(build: impl FnOnce(View<()>) -> View<()>, title: &str, fg: Color) -> View<()> {
let base = View::<()>::new(Style {
size: Size { width: length(180.0_f32), height: length(150.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0_f32), height: length(8.0_f32) },
..Default::default()
})
.radius(16.0);
build(base).children(vec![View::<()>::new(Style {
size: Size { width: percent(0.9_f32), height: length(22.0_f32) },
..Default::default()
})
.text_aligned(title.to_string(), 16.0, fg, Alignment::Center)])
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "primitivas.png".to_string());
let theme = llimphi_theme::Theme::light();
let panel = theme.bg_panel;
let dark = Color::from_rgba8(30, 34, 44, 255);
let white = Color::from_rgba8(248, 248, 250, 255);
// 1) Sombra: fill plano + elevación suave.
let sombra = card(
|v| v.fill(panel).shadow(Shadow::soft(70, 22.0).offset(0.0, 10.0)),
"Sombra",
dark,
);
// 2) Gradiente: relleno vertical claro→oscuro (espacio unidad [0,1]²).
let grad = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0)).with_stops(
[Color::from_rgba8(96, 130, 220, 255), Color::from_rgba8(40, 60, 140, 255)].as_slice(),
);
let gradiente = card(|v| v.fill_gradient(grad.clone()), "Gradiente", white);
// 3) Borde: hairline sobre fill plano (reemplaza el truco del rect-padre).
let borde = card(
|v| v.fill(panel).border(1.5, theme.accent),
"Borde",
dark,
);
// 4) Combo: gradiente + borde + sombra — el look de un botón/card moderno.
let combo_grad = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0)).with_stops(
[Color::from_rgba8(80, 200, 140, 255), Color::from_rgba8(30, 140, 110, 255)].as_slice(),
);
let combo = card(
|v| {
v.fill_gradient(combo_grad)
.border(1.5, Color::from_rgba8(180, 240, 210, 255))
.shadow(Shadow::soft(90, 24.0).offset(0.0, 12.0))
},
"Combo",
white,
);
// 5) Peso de fuente: la misma palabra en 400 (normal) y 700 (bold), para
// contrastar el grosor del trazo en una sola tarjeta.
let line = |txt: &str, bold: bool| {
let v = View::<()>::new(Style {
size: Size { width: percent(0.9_f32), height: length(30.0_f32) },
..Default::default()
})
.text_aligned(txt.to_string(), 24.0, dark, Alignment::Center);
if bold { v.bold() } else { v }
};
let peso = View::<()>::new(Style {
size: Size { width: length(180.0_f32), height: length(150.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
..Default::default()
})
.radius(16.0)
.fill(panel)
.border(1.0, theme.accent)
.children(vec![
line("Regular 400", false),
line("Bold 700", true),
View::<()>::new(Style {
size: Size { width: percent(0.9_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned("Peso", 14.0, dark, Alignment::Center),
]);
// 6) Radio por esquina: esquinas asimétricas (arriba muy redondeadas,
// abajo casi rectas) — el look de una pestaña / bocadillo de chat. El
// borde sigue las cuatro esquinas.
let esquinas = card(
|v| {
v.fill_gradient(grad.clone())
.border(1.5, white)
.radius_corners(34.0, 34.0, 4.0, 4.0)
},
"Esquinas",
white,
);
// 7) Overflow / ellipsis: texto largo clampado a 1 y a 2 líneas, terminando
// en `…`. El ancho de la caja fuerza la envoltura; el clamp recorta.
let long = "Texto largo que no entra en el ancho de esta tarjeta angosta y debe recortarse";
let clamp = |txt: &str, n: usize, h: f32| {
View::<()>::new(Style {
size: Size { width: percent(0.86_f32), height: length(h) },
..Default::default()
})
.text_aligned(txt.to_string(), 13.0, dark, Alignment::Start)
.ellipsis(n)
};
let elipsis = View::<()>::new(Style {
size: Size { width: length(180.0_f32), height: length(150.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.radius(16.0)
.fill(panel)
.border(1.0, theme.accent)
.children(vec![
clamp(long, 1, 18.0),
clamp(long, 2, 36.0),
View::<()>::new(Style {
size: Size { width: percent(0.9_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned("Ellipsis 1·2", 14.0, dark, Alignment::Center),
]);
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(28.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(24.0_f32),
bottom: length(24.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.children(vec![sombra, gradiente, borde, combo, peso, esquinas, elipsis]);
// view → layout → scene (misma secuencia que el eventloop).
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-primitivas"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!("primitivas_demo: escrito {out} ({W}x{H})");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,262 @@
//! Volcado headless de **RichText spans** (Bloque 13 de PARIDAD-FLUTTER:
//! cierra Tier 2 final): un mismo nodo de texto con defaults a nivel
//! bloque (tamaño 16 px, color gris oscuro, weight 400, sin italic) más
//! un arreglo de `TextSpan` que sobreescriben por rango de bytes
//! `weight=700` (bold), `italic=true`, `color`, `underline=true`,
//! `size_px=22` (heading inline), `font_family=mono` (`<code>`-like) y
//! `strikethrough=true`. Verifica que la **medida** y el **pintado**
//! consumen el mismo `layout_spans` (taffy reserva el alto del span más
//! alto en su línea).
//!
//! `cargo run -p llimphi-compositor --example rich_text_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{measure_text_node, mount, paint, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, TextSpan, TextSpanStyle, Typesetter, MONOSPACE};
const W: u32 = 980;
const H: u32 = 380;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
/// Helper para localizar un substring exacto en `text` y devolver el
/// `[start, end)` en bytes — así los spans del demo son legibles ("apply
/// bold to 'NEGRITA'") sin offsets a mano.
fn range_of(text: &str, needle: &str) -> (usize, usize) {
let start = text.find(needle).unwrap_or_else(|| panic!("'{needle}' not found"));
(start, start + needle.len())
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "rich_text.png".to_string());
let theme = llimphi_theme::Theme::light();
let panel = theme.bg_panel;
let dark = rgb(30, 34, 44);
let accent = rgb(52, 152, 219);
let danger = rgb(231, 76, 60);
let muted = rgb(128, 132, 144);
// Párrafo con seis tipos de override + texto plano alrededor para que
// se note el contraste de cada lente.
let parrafo = "Esto es un párrafo que mezcla NEGRITA, cursiva, un \
link.com, un cambio de TAMAÑO inline, una palabra \
tachada y código de muestra inline.";
let (b0, b1) = range_of(parrafo, "NEGRITA");
let (i0, i1) = range_of(parrafo, "cursiva");
let (l0, l1) = range_of(parrafo, "link.com");
let (sz0, sz1) = range_of(parrafo, "TAMAÑO");
let (st0, st1) = range_of(parrafo, "tachada");
let (m0, m1) = range_of(parrafo, "código de muestra");
let spans = vec![
TextSpan::new(b0, b1, TextSpanStyle { weight: Some(700.0), ..Default::default() }),
TextSpan::new(i0, i1, TextSpanStyle { italic: Some(true), ..Default::default() }),
TextSpan::new(
l0,
l1,
TextSpanStyle {
color: Some(accent),
underline: Some(true),
..Default::default()
},
),
TextSpan::new(
sz0,
sz1,
TextSpanStyle {
size_px: Some(24.0),
weight: Some(700.0),
color: Some(rgb(46, 204, 113)),
..Default::default()
},
),
TextSpan::new(
st0,
st1,
TextSpanStyle {
color: Some(danger),
strikethrough: Some(true),
..Default::default()
},
),
TextSpan::new(
m0,
m1,
TextSpanStyle {
font_family: Some(MONOSPACE.to_string()),
color: Some(rgb(155, 89, 182)),
..Default::default()
},
),
];
let texto_rico = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: length(160.0_f32) },
..Default::default()
})
.text_spans(parrafo, 16.0, dark, spans, Alignment::Start);
// Subtítulo + descripción + el párrafo rico, todo dentro de una card
// (apilada con flex_direction Column).
let titulo = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"RichText spans (Bloque 13 — cierra Tier 2)",
18.0,
dark,
Alignment::Start,
)
.bold();
let descripcion = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(
"Un solo nodo, seis tipos de override aplicados por rango de bytes:",
13.0,
muted,
Alignment::Start,
);
let card = View::<()>::new(Style {
size: Size { width: length(W as f32 - 80.0), height: length(H as f32 - 60.0) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::FlexStart),
justify_content: Some(JustifyContent::FlexStart),
gap: Size { width: length(0.0_f32), height: length(12.0_f32) },
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(20.0_f32),
bottom: length(20.0_f32),
},
..Default::default()
})
.fill(panel)
.radius(16.0)
.border(1.0, rgb(220, 224, 232))
.children(vec![titulo, descripcion, texto_rico]);
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_app)
.children(vec![card]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-rich-text"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer
.render_to_view(&hal, &scene, &view, W, H, bg)
.expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"rich_text_demo: escrito {out} ({W}x{H}) — un nodo de texto con \
seis spans aplicados por rango de bytes: bold, italic, link \
(color + underline), heading inline (size 24 + bold + verde), \
strikethrough rojo, y un fragmento en mono morado."
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+182
View File
@@ -0,0 +1,182 @@
//! Filmstrip headless del **ripple/InkWell** (Bloque 8 de PARIDAD-FLUTTER):
//! una fila de botones, cada uno con una salpicadura Material disparada en el
//! mismo punto (arriba-izquierda) pero **observada a un progreso creciente** —
//! de la onda recién nacida (izquierda) a casi extinta (derecha). Muestra el
//! círculo expandiéndose desde el tap, recortado al contorno redondeado del
//! botón, y atenuándose con el fade.
//!
//! Prueba el camino `View::ripple` → `RippleRegistry::trigger`/`paint` →
//! `node_rrect` (clip) → píxeles, sin runtime ni winit. El press real lo
//! sintetiza el runtime (`llimphi-ui`); acá lo emulamos llamando `trigger`.
//!
//! `cargo run -p llimphi-compositor --example ripple_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use std::time::{Duration, Instant};
use llimphi_compositor::{mount, paint, RippleRegistry, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy::prelude::{length, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, LengthPercentage, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
const W: u32 = 1180;
const H: u32 = 240;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const FRAMES: usize = 6;
const DUR: Duration = Duration::from_millis(500);
/// Punto del tap relativo al rect de cada botón (arriba-izquierda) — la onda
/// crece desde ahí hacia el rincón opuesto, bien visible en el filmstrip.
const TAP: (f32, f32) = (38.0, 36.0);
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "ripple.png".to_string());
let fg = rgb(235, 238, 245);
let surface = rgb(44, 52, 70);
let ink = Color::from_rgba8(255, 255, 255, 90); // onda blanca semitransparente
// Una fila de FRAMES botones con ripple (key = columna). Layout real con
// gap/padding → cada botón tiene su propio rect computado (sin transform,
// que el paint del ripple no contempla en v1).
let botones: Vec<View<()>> = (0..FRAMES)
.map(|i| {
let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
View::<()>::new(Style {
size: Size { width: length(150.0), height: length(140.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(surface)
.radius(20.0)
.ripple(i as u64, ink)
.children(vec![View::<()>::new(Style {
size: Size { width: length(130.0), height: length(20.0) },
..Default::default()
})
.text_aligned(format!("{pct}%"), 14.0, fg, Alignment::Center)])
})
.collect();
let root = View::<()>::new(Style {
size: Size { width: length(W as f32), height: length(H as f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(20.0), height: length(0.0) },
padding: Rect {
left: LengthPercentage::length(20.0),
right: LengthPercentage::length(20.0),
top: LengthPercentage::length(0.0),
bottom: LengthPercentage::length(0.0),
},
..Default::default()
})
.children(botones);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = layout.compute(mounted.root, (W as f32, H as f32)).expect("layout");
// Pintá los botones, luego superponé una salpicadura por columna observada
// a un progreso creciente (cada registro disparó en t0, se observa a
// t0 + paso·i). Todas escriben en la misma escena.
let mut ts = Typesetter::new();
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let t0 = Instant::now();
let step = DUR / (FRAMES as u32 - 1);
for i in 0..FRAMES {
let mut reg = RippleRegistry::new();
reg.trigger(i as u64, TAP.0, TAP.1, ink, DUR, t0);
let now = t0 + step * i as u32;
reg.paint(&mut scene, &mounted, &computed, now);
}
// Volcado a PNG.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-ripple"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let bg = rgb(244, 245, 248);
renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"ripple_demo: escrito {out} ({W}x{H}) — {FRAMES} botones, la misma onda \
de {}ms observada a 0→100% (crece desde el tap y se desvanece)",
DUR.as_millis()
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+741
View File
@@ -0,0 +1,741 @@
//! **Showreel** del motor Llimphi — para r/rust. NO es eye-candy abstracto:
//! es una vitrina de **widgets reales** del toolkit, *en acción*. Cada frame
//! reconstruye un árbol `View` con widgets de verdad (`llimphi-widget-switch`,
//! `-slider`, `-progress`, `-button`, `-segmented`) cuyo **estado** se deriva
//! del tiempo normalizado `t∈[0,1]` — el toggle se enciende, el slider sube,
//! la barra avanza, el segmented cambia de pestaña. Se montan con el `mount` /
//! `paint` / `compute_with_measure` reales (taffy + parley + vello), idéntico
//! al eventloop. No se dibujan a mano: si existe el widget, se usa el widget.
//!
//! El render es **headless y determinista** (sin reloj, sin runtime, sin
//! winit): frame `i` de `N` → `t = i/(N-1)` → View → layout → vello::Scene →
//! wgpu → PNG. El cold-open (trazo bezier draw-on) y el wordmark de cierre
//! son `paint_with` sobre un nodo full-screen, superpuestos sobre los widgets.
//!
//! ```text
//! cargo run -p llimphi-compositor --example showreel --release -- \
//! [out_dir] [n_frames] [W] [H]
//! ```
//! Defaults: `out_dir=showreel_frames`, `n_frames=360`, `W=1600`, `H=900`.
use std::fs::{create_dir_all, File};
use std::io::BufWriter;
use llimphi_compositor::{
measure_text_node, mount, paint, DragPhase, PaintRect, Shadow, View,
};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{
auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style,
};
use llimphi_layout::taffy::Rect;
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::{self, Color, Gradient};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter};
use llimphi_theme::motion;
use vello::kurbo::{Affine, BezPath, Circle, Point, Stroke};
use llimphi_widget_button::{button_view, ButtonPalette};
use llimphi_widget_progress::{linear_progress_view, radial_progress_view};
use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
use llimphi_widget_slider::{slider_view, SliderPalette};
use llimphi_widget_switch::{switch_view, SwitchPalette};
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
// ───────────────────────── utilidades ─────────────────────────
/// Color con alpha escalado a `a∈[0,1]` (para fade del overlay vector).
fn with_alpha(c: Color, a: f32) -> Color {
let [r, g, b, _] = c.components;
Color::new([r, g, b, a.clamp(0.0, 1.0)])
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
/// Reescala `t` desde el subintervalo `[lo,hi]` de la timeline a `[0,1]`,
/// clampado. Fuera del intervalo devuelve 0 (antes) o 1 (después).
fn seg(t: f32, lo: f32, hi: f32) -> f32 {
((t - lo) / (hi - lo)).clamp(0.0, 1.0)
}
// ───────────────────────── tema / paleta ─────────────────────────
#[derive(Clone)]
struct Skin {
theme: llimphi_theme::Theme,
accent: Color,
panel: Color,
panel_hi: Color,
border: Color,
border_accent: Color,
fg: Color,
fg_muted: Color,
bg: Color,
}
// ───────────────────────── geometría de las tarjetas ─────────────────────────
#[derive(Clone, Copy)]
struct CardRect {
x: f64,
y: f64,
w: f64,
h: f64,
}
impl CardRect {
fn lerp(self, b: CardRect, t: f64) -> CardRect {
CardRect {
x: lerp(self.x, b.x, t),
y: lerp(self.y, b.y, t),
w: lerp(self.w, b.w, t),
h: lerp(self.h, b.h, t),
}
}
}
const N_CARDS: usize = 6;
/// Disposición A — grilla 3×2 centrada (beat de ensamblado).
fn layout_grid(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
let card_w = 360.0;
let card_h = 196.0;
let gap = 40.0;
let cols = 3.0;
let rows = 2.0;
let total_w = cols * card_w + (cols - 1.0) * gap;
let total_h = rows * card_h + (rows - 1.0) * gap;
let x0 = (cw - total_w) / 2.0;
let y0 = (ch - total_h) / 2.0;
let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: card_h }; N_CARDS];
for (i, c) in out.iter_mut().enumerate() {
let col = (i % 3) as f64;
let row = (i / 3) as f64;
c.x = x0 + col * (card_w + gap);
c.y = y0 + row * (card_h + gap);
}
out
}
/// Disposición B — fila única ancha, alturas escalonadas (beat de morph).
/// Los MISMOS widgets adentro, otra geometría: "cualquier layout con taffy".
fn layout_row(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
let gap = 22.0;
let n = N_CARDS as f64;
let card_w = (cw - 2.0 * 90.0 - (n - 1.0) * gap) / n;
let x0 = 90.0;
let cy = ch / 2.0;
// alturas tipo "ecualizador" — silueta dinámica al reacomodar.
let hs = [240.0, 300.0, 210.0, 320.0, 260.0, 230.0];
let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: 220.0 }; N_CARDS];
for (i, c) in out.iter_mut().enumerate() {
c.x = x0 + i as f64 * (card_w + gap);
c.h = hs[i];
c.y = cy - c.h / 2.0;
c.w = card_w;
}
out
}
// ───────────────────────── contenido de cada card ─────────────────────────
/// Header de card: chip de acento + título.
fn card_header(title: &str, s: &Skin, accented: bool) -> View<()> {
let chip = View::new(Style {
size: Size { width: length(28.0), height: length(8.0) },
flex_shrink: 0.0,
..Default::default()
})
.radius(4.0)
.fill(if accented { s.accent } else { s.fg_muted });
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(10.0), height: length(0.0) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![
chip,
View::new(Style { flex_grow: 1.0, ..Default::default() })
.text_aligned(title.to_string(), 12.5, s.fg_muted, Alignment::Start)
.bold(),
])
}
/// Línea de "valor" grande (estado legible) bajo el control.
fn value_line(text: &str, color: Color, size: f32) -> View<()> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(size + 6.0) },
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(text.to_string(), size, color, Alignment::Start)
.bold()
}
/// Cuerpo de una card según índice — cada una hospeda widgets REALES cuyo
/// estado deriva de `p∈[0,1]` (progreso del beat de widgets).
fn card_body(i: usize, p: f32, s: &Skin) -> Vec<View<()>> {
match i {
// ── 0: Switch (off → on) ──────────────────────────────────────
0 => {
// El thumb se desliza en una rampa centrada del beat.
let prog = motion::ease_in_out_cubic(seg(p, 0.15, 0.6));
let on = prog > 0.5;
let pal = SwitchPalette::from_theme(&s.theme);
let sw_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(26.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(14.0), height: length(0.0) },
..Default::default()
})
.children(vec![
switch_view(prog, (), &pal),
View::new(Style { flex_grow: 1.0, ..Default::default() })
.text_aligned(
"Sincronizar".to_string(),
13.0,
s.fg,
Alignment::Start,
),
]);
vec![
card_header("switch", s, true),
spacer(8.0),
sw_row,
spacer(10.0),
value_line(if on { "ENCENDIDO" } else { "apagado" }, if on { s.accent } else { s.fg_muted }, 22.0),
]
}
// ── 1: Slider (20% → 75%) ─────────────────────────────────────
1 => {
let v = lerp(0.2, 0.75, motion::ease_in_out_cubic(seg(p, 0.1, 0.7)) as f64) as f32;
let mut pal = SliderPalette::from_theme(&s.theme);
pal.track_width = 168.0;
pal.label_width = 0.0;
pal.value_width = 50.0;
pal.track_thickness = 8.0;
pal.row_height = 26.0;
let sld = slider_view::<(), _>(
"",
v,
0.0,
1.0,
&pal,
|_phase: DragPhase, _dv: f32| None,
);
vec![
card_header("slider", s, false),
spacer(10.0),
sld,
spacer(12.0),
value_line(&format!("{:>3.0}%", v * 100.0), s.fg, 26.0),
]
}
// ── 2: Linear progress (avanza) ───────────────────────────────
2 => {
let v = motion::ease_out_cubic(seg(p, 0.05, 0.85));
let bar = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(12.0) },
position: Position::Relative,
..Default::default()
})
.fill(s.theme.bg_button)
.radius(6.0)
.children(vec![linear_progress_view(
v,
s.theme.bg_button,
s.accent,
12.0,
)]);
vec![
card_header("progress", s, true),
spacer(14.0),
bar,
spacer(14.0),
value_line(&format!("{:>3.0}% · compilando", v * 100.0), s.fg_muted, 13.0),
]
}
// ── 3: Segmented control (cambia de pestaña activa) ───────────
3 => {
// 3 segmentos; el activo recorre 0 → 1 → 2 a lo largo del beat.
let phase = seg(p, 0.1, 0.95);
let active = ((phase * 3.0).floor() as usize).min(2);
let labels = ["Día", "Semana", "Mes"];
let pal = SegmentedPalette::from_theme(&s.theme);
let seg_ctrl = segmented_view::<(), _>(&labels, active, |_| (), &pal);
vec![
card_header("segmented", s, false),
spacer(14.0),
seg_ctrl,
spacer(14.0),
value_line(labels[active], s.accent, 22.0),
]
}
// ── 4: Botones (primario teal + ghost) ────────────────────────
4 => {
// Paleta primaria: fondo teal, texto sobre fondo.
let mut prim = ButtonPalette::from_theme(&s.theme);
prim.bg = s.accent;
prim.bg_hover = s.accent;
prim.fg = s.bg; // texto oscuro sobre teal
prim.radius = 8.0;
let mut ghost = ButtonPalette::from_theme(&s.theme);
ghost.bg = s.theme.bg_button;
ghost.fg = s.fg;
ghost.radius = 8.0;
let row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(38.0) },
flex_direction: FlexDirection::Row,
gap: Size { width: length(12.0), height: length(0.0) },
..Default::default()
})
.children(vec![
View::new(Style {
size: Size { width: length(132.0), height: length(38.0) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![button_view("Regenerar", &prim, ())]),
View::new(Style {
size: Size { width: length(110.0), height: length(38.0) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![button_view("Difundir", &ghost, ())]),
]);
vec![
card_header("button", s, true),
spacer(14.0),
row,
spacer(14.0),
value_line("primario · ghost", s.fg_muted, 13.0),
]
}
// ── 5: Radial progress (anillo que se llena) ──────────────────
_ => {
let v = motion::ease_out_cubic(seg(p, 0.1, 0.9));
let ring = View::new(Style {
size: Size { width: length(96.0), height: length(96.0) },
position: Position::Relative,
..Default::default()
})
.children(vec![radial_progress_view(
v,
s.theme.bg_button,
s.accent,
0.14,
)]);
let ring_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(96.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.children(vec![ring]);
vec![
card_header("radial", s, false),
spacer(6.0),
ring_row,
]
}
}
}
/// Espaciador vertical de alto fijo.
fn spacer(h: f32) -> View<()> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(h) },
flex_shrink: 0.0,
..Default::default()
})
}
/// Una card como contenedor absoluto, hospedando widgets reales.
fn card_view(i: usize, rect: CardRect, alpha: f32, scale: f64, p: f32, s: &Skin) -> View<()> {
let accented = i == 0 || i == 2 || i == 4;
let border_col = if accented { s.border_accent } else { s.border };
// Pop de entrada: escala desde el centro de la card.
let cx = rect.x + rect.w / 2.0;
let cy = rect.y + rect.h / 2.0;
let xf = Affine::translate((cx, cy)) * Affine::scale(scale) * Affine::translate((-cx, -cy));
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(rect.x as f32),
top: length(rect.y as f32),
right: auto(),
bottom: auto(),
},
size: Size { width: length(rect.w as f32), height: length(rect.h as f32) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(0.0) },
padding: Rect {
left: length(22.0),
right: length(22.0),
top: length(20.0),
bottom: length(18.0),
},
..Default::default()
})
.fill_gradient(
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
.with_stops([s.panel_hi, s.panel].as_slice()),
)
.radius(18.0)
.border(if accented { 1.4 } else { 1.0 }, border_col)
.shadow(Shadow::soft(120, 26.0).offset(0.0, 12.0))
.transform(xf)
.alpha(alpha)
.children(card_body(i, p, s))
}
// ───────────────────────── overlays vector (cold-open + wordmark) ─────────────────────────
/// Curva bezier "firma" del cold-open.
fn signature_path(cw: f64, ch: f64) -> BezPath {
let cx = cw / 2.0;
let cy = ch / 2.0;
let mut p = BezPath::new();
p.move_to((cx - 360.0, cy + 40.0));
p.curve_to(
(cx - 150.0, cy - 220.0),
(cx + 150.0, cy + 220.0),
(cx + 360.0, cy - 40.0),
);
p
}
/// Recorta un `BezPath` cúbico a su fracción inicial `prog`. Devuelve la
/// cabeza del trazo para anclar el punto teal.
fn trim_path(full: &BezPath, prog: f64) -> (BezPath, Point) {
use vello::kurbo::ParamCurve;
let prog = prog.clamp(0.0, 1.0);
let mut cubic = None;
let mut start = Point::ZERO;
for el in full.elements() {
match el {
vello::kurbo::PathEl::MoveTo(p) => start = *p,
vello::kurbo::PathEl::CurveTo(c1, c2, p) => {
cubic = Some(vello::kurbo::CubicBez::new(start, *c1, *c2, *p));
}
_ => {}
}
}
let mut out = BezPath::new();
let mut head = start;
if let Some(cb) = cubic {
out.move_to(cb.p0);
let steps = 96;
for i in 1..=steps {
let u = (i as f64 / steps as f64) * prog;
let pt = cb.eval(u);
out.line_to(pt);
head = pt;
}
}
(out, head)
}
/// Dibuja los overlays vector (cold-open + wordmark + punto firma) sobre un
/// nodo full-screen, en función de `t`. Los widgets ya están pintados debajo.
fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64, s: &Skin) {
// ── COLD OPEN (0–12%) ──────────────────────────────────────────
let b1 = seg(t, 0.0, 0.12);
let line_vis = 1.0 - seg(t, 0.12, 0.20);
if line_vis > 0.001 {
let path = signature_path(cw, ch);
let draw_on = motion::ease_out_cubic(seg(t, 0.02, 0.13)) as f64;
let (trimmed, head) = trim_path(&path, draw_on);
let line_col = with_alpha(s.accent, 0.9 * line_vis);
scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, line_col, None, &trimmed);
let pop = motion::ease_out_back(b1);
let r = (4.0 + 7.0 * pop as f64).max(0.0);
let dot_a = (b1 * line_vis).clamp(0.0, 1.0);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.18 * dot_a),
None,
&Circle::new(head, r * 3.2),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, dot_a),
None,
&Circle::new(head, r),
);
}
// ── WORDMARK (82100%) ─────────────────────────────────────────
let word_in = seg(t, 0.84, 0.95);
let word_a = motion::ease_out_cubic(word_in);
if word_a > 0.001 {
let size = 132.0_f32;
let layout = ts.layout(
"Llimphi", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0,
);
let m = measurement(&layout);
let rise = lerp(24.0, 0.0, word_a as f64);
let ox = (cw - m.width as f64) / 2.0;
let oy = (ch - m.height as f64) / 2.0 - 18.0 + rise;
let brush = peniko::Brush::Solid(with_alpha(s.fg, word_a));
draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
let sub_a = motion::ease_out_cubic(seg(t, 0.88, 0.99));
if sub_a > 0.001 {
let ssz = 26.0_f32;
let sub = ts.layout(
"a Rust GUI framework", ssz, None, Alignment::Start, 1.0, false, None, 400.0,
false, false, 0.0, 0.0,
);
let sm = measurement(&sub);
let dot_r = 6.0;
let block_w = sm.width as f64 + dot_r * 2.0 + 14.0;
let sx = (cw - block_w) / 2.0;
let sy = oy + m.height as f64 + 18.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, sub_a),
None,
&Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r as f64),
);
let sbrush = peniko::Brush::Solid(with_alpha(s.fg_muted, sub_a));
draw_layout_brush_xf(
scene,
&sub,
&sbrush,
Affine::translate((sx + dot_r * 2.0 + 14.0, sy)),
);
}
}
// ── punto teal de firma (esquina inf-der), ancla de marca ───────
let corner_a = seg(t, 0.04, 0.12) * (1.0 - seg(t, 0.80, 0.86));
if corner_a > 0.001 {
let cx = cw - 54.0;
let cy = ch - 54.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.16 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 18.0),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.9 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 6.0),
);
}
}
// ───────────────────────── la escena por frame ─────────────────────────
/// Construye el árbol `View` completo del frame `t`: las cards con widgets
/// reales (con su estado derivado de t) + un nodo overlay full-screen que
/// pinta cold-open / wordmark encima.
fn build_view(t: f32, cw: f64, ch: f64, s: &Skin) -> View<()> {
let grid = layout_grid(cw, ch);
let row = layout_row(cw, ch);
// Progreso del "estado" de los widgets (toggle/slider/progress/…).
let widget_p = seg(t, 0.16, 0.58);
// Morph grid → fila (5880%).
let morph = motion::ease_in_out_cubic(seg(t, 0.60, 0.80)) as f64;
// Fade-out de las cards antes del wordmark.
let cards_fade = 1.0 - seg(t, 0.80, 0.86);
let mut children: Vec<View<()>> = Vec::new();
if cards_fade > 0.001 {
for i in 0..N_CARDS {
// Stagger de entrada: cada card arranca con retraso incremental.
let delay = i as f32 * 0.035;
let enter = motion::ease_out_back(seg(t, 0.12 + delay, 0.12 + delay + 0.16));
if enter <= 0.001 {
continue;
}
let rect = grid[i].lerp(row[i], morph);
let scale = lerp(0.88, 1.0, enter.min(1.0) as f64);
let alpha = (enter.min(1.0) * cards_fade).clamp(0.0, 1.0);
children.push(card_view(i, rect, alpha, scale, widget_p, s));
}
}
// Nodo overlay full-screen para el vector (cold-open + wordmark).
let overlay = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
right: length(0.0),
bottom: length(0.0),
},
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.paint_with({
let s = s.clone();
move |scene, ts, _rect: PaintRect| {
draw_overlays(scene, ts, t, cw, ch, &s);
}
});
children.push(overlay);
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
position: Position::Relative,
..Default::default()
})
.fill(s.bg)
.children(children)
}
fn main() {
let mut args = std::env::args().skip(1);
let out_dir = args.next().unwrap_or_else(|| "showreel_frames".to_string());
let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(360);
let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
create_dir_all(&out_dir).expect("mkdir out_dir");
let theme = llimphi_theme::Theme::by_name("Tawa").expect("tema Tawa");
let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF); // teal #2BD9A6 (acento firma)
let skin = Skin {
accent,
panel: theme.bg_panel,
panel_hi: theme.bg_button,
border: theme.border,
border_accent: with_alpha(accent, 0.55),
fg: theme.fg_text,
fg_muted: theme.fg_muted,
bg: theme.bg_app,
theme,
};
let [br, bg, bb, _] = skin.bg.components;
let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255);
// GPU una sola vez; reusar device/renderer/target/buffer para los N frames.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("showreel"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let mut ts = Typesetter::new();
let cw = w as f64;
let ch = h as f64;
for i in 0..n {
let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
let root = build_view(t, cw, ch, &skin);
// view → layout (con medición de texto real) → scene — idéntico al eventloop.
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
renderer
.render_to_view(&hal, &scene, &view, w, h, base)
.expect("render_to_view");
let path = format!("{out_dir}/frame_{i:04}.png");
write_png(&hal, &target, &path, w, h);
if i % 30 == 0 || i == n - 1 {
eprintln!("showreel: frame {}/{} (t={:.3})", i + 1, n, t);
}
}
eprintln!("showreel: {n} frames en {out_dir}/ ({w}x{h})");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * h as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for r in 0..h as usize {
let sidx = r * padded;
pixels.extend_from_slice(&data[sidx..sidx + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wr = enc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}