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:
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Filmstrip headless de la **familia `filter`** (Fases 7.1232–7.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, ®las);
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 (82–100%) ─────────────────────────────────────────
|
||||
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 (58–80%).
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user