tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 238TOML 102Markdown 83Shell 24178 Sutichaykuna

⎇ main llimphi-compositor/examples/pantallazo_motor.rs Rust · 697 siq'ikuna · 30.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! Pantallazo headless del motor — **una UI real y densa** compuesta sólo con
2//! primitivas del compositor (View → layout → vello → wgpu → PNG), pensada
3//! para la tarjeta pública "Un motor gráfico soberano".
4//!
5//! Muestra en una sola pasada: tema oscuro de `llimphi-theme`, top bar con
6//! tabs, sidebar con filas seleccionadas, un editor de código con resaltado
7//! sintáctico vía `TextSpan`s sobre fuente mono, un párrafo de texto rico
8//! (pesos, cursiva, subrayado, mono inline), tarjetas de métricas con
9//! gradientes y sombras, un mini gráfico de barras hecho con puros rects,
10//! chips/botones, y un toast flotante con esquinas asimétricas.
11//!
12//! `cargo run -p llimphi-compositor --example pantallazo_motor --release -- [out.png]`
13
14use std::fs::File;
15use std::io::BufWriter;
16
17use llimphi_compositor::{measure_text_node, mount, paint, Shadow, View};
18use llimphi_hal::{wgpu, Hal};
19use llimphi_layout::taffy;
20use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
21use llimphi_layout::taffy::{AlignItems, JustifyContent, Position, Rect};
22use llimphi_layout::LayoutTree;
23use llimphi_raster::peniko::{Color, Gradient};
24use llimphi_raster::{vello, Renderer};
25use llimphi_text::{Alignment, TextSpan, TextSpanStyle, Typesetter};
26use vello::kurbo::Point;
27
28const W: u32 = 1280;
29const H: u32 = 800;
30const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
31
32fn rgb(r: u8, g: u8, b: u8) -> Color {
33    Color::from_rgba8(r, g, b, 255)
34}
35
36fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
37    Color::from_rgba8(r, g, b, a)
38}
39
40/// Caja vacía con tamaño fijo — separadores, swatches, barras.
41fn rect(w: f32, h: f32) -> View<()> {
42    View::<()>::new(Style { size: Size { width: length(w), height: length(h) }, ..Default::default() })
43}
44
45/// Nodo de texto de una línea con alto fijo (mismo patrón que los demos vecinos).
46fn txt(w: taffy::Dimension, h: f32, s: &str, size: f32, c: Color) -> View<()> {
47    View::<()>::new(Style { size: Size { width: w, height: length(h) }, ..Default::default() })
48        .text_aligned(s.to_string(), size, c, Alignment::Start)
49}
50
51/// Spans sintácticos: pinta TODAS las ocurrencias de cada `needle` con su estilo.
52fn spans_all(text: &str, reglas: &[(&str, TextSpanStyle)]) -> Vec<TextSpan> {
53    let mut out = Vec::new();
54    for (needle, style) in reglas {
55        for (i, _) in text.match_indices(needle) {
56            out.push(TextSpan::new(i, i + needle.len(), style.clone()));
57        }
58    }
59    out
60}
61
62fn color_span(c: Color) -> TextSpanStyle {
63    TextSpanStyle { color: Some(c), ..Default::default() }
64}
65
66fn main() {
67    let out = std::env::args().nth(1).unwrap_or_else(|| "pantallazo_motor.png".to_string());
68    // Default dark (el look histórico de esta tarjeta); `LLIMPHI_THEME=<nombre>`
69    // fuerza otro preset por nombre canónico (p. ej. `Tawa`) para evidenciar la
70    // paleta firma sin alterar el default del pipeline público.
71    let theme = std::env::var("LLIMPHI_THEME")
72        .ok()
73        .and_then(|n| llimphi_theme::Theme::by_name(&n))
74        .unwrap_or_else(llimphi_theme::Theme::dark);
75
76    // Paleta de sintaxis (sobre el panel oscuro del theme).
77    let kw = rgb(198, 120, 221); // keywords — violeta
78    let ty = rgb(229, 192, 123); // tipos — ámbar
79    let fnc = rgb(97, 175, 239); // funciones — azul
80    let _strv = rgb(152, 195, 121); // strings — verde
81    let cmt = rgb(92, 104, 124); // comentarios — gris azulado
82    let lit = rgb(209, 154, 102); // literales numéricos — naranja
83    let code_fg = rgb(171, 178, 191);
84
85    // ───────────────────────────── top bar ─────────────────────────────
86    let tab = |name: &str, activo: bool| {
87        let base = View::<()>::new(Style {
88            size: Size { width: auto(), height: length(30.0) },
89            align_items: Some(AlignItems::CENTER),
90            padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
91            ..Default::default()
92        })
93        .radius(8.0);
94        let fg = if activo { theme.fg_text } else { theme.fg_muted };
95        let v = if activo { base.fill(theme.bg_selected) } else { base };
96        v.children(vec![txt(auto(), 18.0, name, 13.0, fg)])
97    };
98    let brand_dot = rect(10.0, 10.0)
99        .radius(5.0)
100        .fill_gradient(
101            Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
102                .with_stops([theme.accent, rgb(80, 200, 200)].as_slice()),
103        );
104    let buscador = View::<()>::new(Style {
105        size: Size { width: length(230.0), height: length(28.0) },
106        align_items: Some(AlignItems::CENTER),
107        padding: Rect { left: length(12.0), right: length(12.0), top: length(0.0), bottom: length(0.0) },
108        ..Default::default()
109    })
110    .fill(theme.bg_input)
111    .radius(14.0)
112    .border(1.0, theme.border)
113    .children(vec![txt(auto(), 16.0, "buscar en el haz…   ⌘K", 12.0, theme.fg_placeholder)]);
114    let topbar = View::<()>::new(Style {
115        size: Size { width: percent(1.0), height: length(46.0) },
116        flex_direction: FlexDirection::Row,
117        align_items: Some(AlignItems::CENTER),
118        gap: Size { width: length(10.0), height: length(0.0) },
119        padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
120        ..Default::default()
121    })
122    .fill(theme.bg_panel_alt)
123    .border(1.0, theme.border)
124    .children(vec![
125        brand_dot,
126        txt(auto(), 18.0, "llimphi", 15.0, theme.fg_text).bold(),
127        rect(14.0, 1.0),
128        tab("pluma", true),
129        tab("khipu", false),
130        tab("cosmos", false),
131        tab("shuma", false),
132        // empuja el buscador a la derecha
133        View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
134        buscador,
135    ]);
136
137    // ───────────────────────────── sidebar ─────────────────────────────
138    let fila = |nombre: &str, badge: Option<&str>, sel: bool, dot: Color| {
139        let base = View::<()>::new(Style {
140            size: Size { width: percent(1.0), height: length(30.0) },
141            flex_direction: FlexDirection::Row,
142            align_items: Some(AlignItems::CENTER),
143            justify_content: Some(JustifyContent::SPACE_BETWEEN),
144            padding: Rect { left: length(10.0), right: length(10.0), top: length(0.0), bottom: length(0.0) },
145            ..Default::default()
146        })
147        .radius(7.0);
148        let v = if sel { base.fill(theme.bg_selected) } else { base };
149        let izq = View::<()>::new(Style {
150            flex_direction: FlexDirection::Row,
151            align_items: Some(AlignItems::CENTER),
152            gap: Size { width: length(8.0), height: length(0.0) },
153            ..Default::default()
154        })
155        .children(vec![
156            rect(8.0, 8.0).radius(2.5).fill(dot),
157            txt(length(135.0), 17.0, nombre, 13.0, if sel { theme.fg_text } else { theme.fg_muted }),
158        ]);
159        let mut hijos = vec![izq];
160        if let Some(b) = badge {
161            hijos.push(
162                View::<()>::new(Style {
163                    size: Size { width: auto(), height: length(17.0) },
164                    align_items: Some(AlignItems::CENTER),
165                    padding: Rect { left: length(7.0), right: length(7.0), top: length(0.0), bottom: length(0.0) },
166                    ..Default::default()
167                })
168                .fill(theme.bg_button)
169                .radius(8.5)
170                .children(vec![txt(auto(), 13.0, b, 10.5, theme.fg_muted)]),
171            );
172        }
173        v.children(hijos)
174    };
175    let seccion = |t: &str| txt(percent(1.0), 16.0, t, 11.0, theme.fg_placeholder).bold();
176    let sidebar = View::<()>::new(Style {
177        size: Size { width: length(236.0), height: percent(1.0) },
178        flex_shrink: 0.0,
179        flex_direction: FlexDirection::Column,
180        gap: Size { width: length(0.0), height: length(4.0) },
181        padding: Rect { left: length(12.0), right: length(12.0), top: length(14.0), bottom: length(14.0) },
182        ..Default::default()
183    })
184    .fill(theme.bg_panel)
185    .border(1.0, theme.border)
186    .children(vec![
187        seccion("HAZ DE CUERPOS"),
188        fila("ensayo · español", None, true, theme.accent),
189        fila("ensayo · english", Some("stale"), false, rgb(209, 154, 102)),
190        fila("ensayo · runasimi", None, false, rgb(80, 200, 200)),
191        fila("resumen ejecutivo", None, false, rgb(152, 195, 121)),
192        rect(1.0, 10.0),
193        seccion("MÓDULOS"),
194        fila("nodegraph.rs", Some("12"), false, fnc),
195        fila("text_editor.rs", Some("3"), false, fnc),
196        fila("typesetter.rs", None, false, fnc),
197        fila("raster/scene.rs", None, false, fnc),
198        rect(1.0, 10.0),
199        seccion("DAEMONS"),
200        fila("verbo · e5-small", Some("384d"), false, rgb(152, 195, 121)),
201        fila("chasqui · DHT", Some("9"), false, rgb(152, 195, 121)),
202    ]);
203
204    // ─────────────────────── editor de código (centro) ───────────────────────
205    // (líneas unidas a mano — la continuación `\` de Rust se comería la indentación)
206    let codigo = [
207        "// bucle Elm del motor: input → update → view → layout → raster",
208        "pub fn frame(&mut self, msg: Msg) -> Scene {",
209        "    self.app.update(msg);",
210        "    let view = self.app.view();",
211        "    let tree = mount(&mut self.layout, view);",
212        "    let computed = self.layout.compute(tree.root, self.size);",
213        "    let mut scene = Scene::new();",
214        "    paint(&mut scene, &tree, &computed, &mut self.ts);",
215        "    scene // vello la rasteriza en GPU vía wgpu",
216        "}",
217        "",
218        "let sombra = Shadow::soft(90, 24.0).offset(0.0, 12.0);",
219        "let card = View::new(estilo)",
220        "    .fill_gradient(grad)      // gradiente en [0,1]²",
221        "    .radius_corners(18.0, 18.0, 4.0, 4.0)",
222        "    .shadow(sombra);",
223    ]
224    .join("\n");
225    let codigo = codigo.as_str();
226    let reglas = [
227        ("// bucle Elm del motor: input → update → view → layout → raster", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
228        ("// vello la rasteriza en GPU vía wgpu", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
229        ("// gradiente en [0,1]²", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
230        ("pub fn ", color_span(kw)),
231        ("let ", color_span(kw)),
232        ("&mut ", color_span(kw)),
233        ("self", color_span(rgb(224, 108, 117))),
234        ("Msg", color_span(ty)),
235        ("Scene", color_span(ty)),
236        ("Shadow", color_span(ty)),
237        ("View", color_span(ty)),
238        ("frame", TextSpanStyle { color: Some(fnc), weight: Some(700.0), ..Default::default() }),
239        ("update", color_span(fnc)),
240        ("view()", color_span(fnc)),
241        ("mount", color_span(fnc)),
242        ("compute", color_span(fnc)),
243        ("new", color_span(fnc)),
244        ("paint", color_span(fnc)),
245        ("soft", color_span(fnc)),
246        ("offset", color_span(fnc)),
247        ("fill_gradient", color_span(fnc)),
248        ("radius_corners", color_span(fnc)),
249        ("shadow(", color_span(fnc)),
250        ("90, 24.0", color_span(lit)),
251        ("0.0, 12.0", color_span(lit)),
252        ("18.0, 18.0, 4.0, 4.0", color_span(lit)),
253    ];
254    let code_spans = spans_all(codigo, &reglas);
255    let code_text = View::<()>::new(Style {
256        size: Size { width: percent(1.0), height: length(360.0) },
257        ..Default::default()
258    })
259    .text_spans(codigo, 13.5, code_fg, code_spans, Alignment::Start)
260    .mono()
261    .line_height(1.55);
262    // header del editor: tres puntos + nombre de archivo + chip de lenguaje
263    let punto = |c: Color| rect(11.0, 11.0).radius(5.5).fill(c);
264    let editor_header = View::<()>::new(Style {
265        size: Size { width: percent(1.0), height: length(36.0) },
266        flex_direction: FlexDirection::Row,
267        align_items: Some(AlignItems::CENTER),
268        gap: Size { width: length(8.0), height: length(0.0) },
269        padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
270        ..Default::default()
271    })
272    .fill(theme.bg_panel_alt)
273    .radius_corners(12.0, 12.0, 0.0, 0.0)
274    .children(vec![
275        punto(rgb(224, 108, 117)),
276        punto(rgb(229, 192, 123)),
277        punto(rgb(152, 195, 121)),
278        rect(6.0, 1.0),
279        txt(auto(), 17.0, "eventloop.rs", 13.0, theme.fg_text).mono(),
280        txt(length(96.0), 16.0, "— llimphi-ui", 12.0, theme.fg_placeholder),
281        View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
282        txt(auto(), 16.0, "rust", 11.0, theme.accent).mono(),
283    ]);
284    let editor = View::<()>::new(Style {
285        size: Size { width: percent(1.0), height: auto() },
286        flex_grow: 1.0,
287        flex_direction: FlexDirection::Column,
288        ..Default::default()
289    })
290    .fill(theme.bg_panel)
291    .radius(12.0)
292    .border(1.0, theme.border)
293    .shadow(Shadow::soft(110, 26.0).offset(0.0, 12.0))
294    .children(vec![
295        editor_header,
296        View::<()>::new(Style {
297            size: Size { width: percent(1.0), height: auto() },
298            flex_grow: 1.0,
299            padding: Rect { left: length(16.0), right: length(16.0), top: length(12.0), bottom: length(8.0) },
300            ..Default::default()
301        })
302        .children(vec![code_text]),
303    ]);
304
305    // ─────────────────── párrafo rico (debajo del editor) ───────────────────
306    let parrafo = "Un solo nodo de texto, varios lentes por rango de bytes: \
307NEGRITA para el énfasis, cursiva para la voz, un enlace.qu subrayado, \
308texto tachado para lo descartado, y Typesetter en mono inline — todo \
309medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
310    let find = |n: &str| {
311        let i = parrafo.find(n).expect("needle");
312        (i, i + n.len())
313    };
314    let (b0, b1) = find("NEGRITA");
315    let (i0, i1) = find("cursiva");
316    let (l0, l1) = find("enlace.qu");
317    let (t0, t1) = find("tachado");
318    let (m0, m1) = find("Typesetter");
319    let rich_spans = vec![
320        TextSpan::new(b0, b1, TextSpanStyle { weight: Some(700.0), color: Some(theme.fg_text), ..Default::default() }),
321        TextSpan::new(i0, i1, TextSpanStyle { italic: Some(true), color: Some(theme.fg_text), ..Default::default() }),
322        TextSpan::new(l0, l1, TextSpanStyle { color: Some(theme.accent), underline: Some(true), ..Default::default() }),
323        TextSpan::new(t0, t1, TextSpanStyle { color: Some(theme.fg_destructive), strikethrough: Some(true), ..Default::default() }),
324        TextSpan::new(m0, m1, TextSpanStyle { font_family: Some(llimphi_text::MONOSPACE.to_string()), color: Some(rgb(80, 200, 200)), ..Default::default() }),
325    ];
326    let rich_card = View::<()>::new(Style {
327        size: Size { width: percent(1.0), height: length(150.0) },
328        flex_direction: FlexDirection::Column,
329        gap: Size { width: length(0.0), height: length(8.0) },
330        padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
331        ..Default::default()
332    })
333    .fill(theme.bg_panel)
334    .radius(12.0)
335    .border(1.0, theme.border)
336    .children(vec![
337        txt(percent(1.0), 20.0, "Texto rico — spans nativos", 14.5, theme.fg_text).bold(),
338        View::<()>::new(Style {
339            size: Size { width: percent(1.0), height: length(78.0) },
340            ..Default::default()
341        })
342        .text_spans(parrafo, 13.5, theme.fg_muted, rich_spans, Alignment::Start)
343        .line_height(1.45),
344    ]);
345
346    let centro = View::<()>::new(Style {
347        size: Size { width: auto(), height: percent(1.0) },
348        flex_grow: 1.0,
349        flex_direction: FlexDirection::Column,
350        gap: Size { width: length(0.0), height: length(14.0) },
351        padding: Rect { left: length(14.0), right: length(14.0), top: length(14.0), bottom: length(14.0) },
352        ..Default::default()
353    })
354    .children(vec![editor, rich_card]);
355
356    // ───────────────────────── columna derecha ─────────────────────────
357    // 1) Tarjeta de métricas con gradiente + sombra (el look "hero card").
358    let grad_hero = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
359        .with_stops([rgb(64, 92, 180), rgb(34, 46, 96)].as_slice());
360    let metrica = |valor: &str, label: &str| {
361        View::<()>::new(Style {
362            size: Size { width: length(80.0), height: auto() },
363            flex_shrink: 0.0,
364            flex_direction: FlexDirection::Column,
365            gap: Size { width: length(0.0), height: length(2.0) },
366            ..Default::default()
367        })
368        .children(vec![
369            txt(length(80.0), 26.0, valor, 21.0, rgb(240, 244, 252)).bold(),
370            txt(length(80.0), 15.0, label, 11.0, rgba(214, 222, 240, 190)),
371        ])
372    };
373    let hero = View::<()>::new(Style {
374        size: Size { width: percent(1.0), height: length(120.0) },
375        flex_direction: FlexDirection::Column,
376        justify_content: Some(JustifyContent::CENTER),
377        gap: Size { width: length(0.0), height: length(10.0) },
378        padding: Rect { left: length(18.0), right: length(18.0), top: length(12.0), bottom: length(12.0) },
379        ..Default::default()
380    })
381    .fill_gradient(grad_hero)
382    .radius(14.0)
383    .border(1.0, rgba(150, 175, 240, 120))
384    .shadow(Shadow::soft(120, 28.0).offset(0.0, 14.0))
385    .children(vec![
386        txt(percent(1.0), 16.0, "RENDER · ÚLTIMO FRAME", 11.0, rgba(214, 222, 240, 200)).bold(),
387        View::<()>::new(Style {
388            flex_direction: FlexDirection::Row,
389            gap: Size { width: length(16.0), height: length(0.0) },
390            ..Default::default()
391        })
392        .children(vec![metrica("1.8 ms", "scene → GPU"), metrica("2 411", "nodos"), metrica("60 fps", "vsync")]),
393    ]);
394
395    // 2) Mini gráfico de barras: puros rects con gradiente, alineados al piso.
396    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];
397    let barras: Vec<View<()>> = alturas
398        .iter()
399        .enumerate()
400        .map(|(i, &h)| {
401            let g = if i == 10 {
402                Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
403                    .with_stops([rgb(120, 220, 200), rgb(60, 150, 140)].as_slice())
404            } else {
405                Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
406                    .with_stops([rgb(110, 140, 220), rgb(58, 78, 128)].as_slice())
407            };
408            rect(15.0, h).radius_corners(4.0, 4.0, 0.0, 0.0).fill_gradient(g)
409        })
410        .collect();
411    let chart = View::<()>::new(Style {
412        size: Size { width: percent(1.0), height: length(168.0) },
413        flex_direction: FlexDirection::Column,
414        gap: Size { width: length(0.0), height: length(10.0) },
415        padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
416        ..Default::default()
417    })
418    .fill(theme.bg_panel)
419    .radius(12.0)
420    .border(1.0, theme.border)
421    .children(vec![
422        View::<()>::new(Style {
423            size: Size { width: percent(1.0), height: length(18.0) },
424            flex_direction: FlexDirection::Row,
425            justify_content: Some(JustifyContent::SPACE_BETWEEN),
426            ..Default::default()
427        })
428        .children(vec![
429            txt(length(200.0), 18.0, "Throughput del raster", 13.0, theme.fg_text).bold(),
430            View::<()>::new(Style {
431                size: Size { width: length(70.0), height: length(16.0) },
432                ..Default::default()
433            })
434            .text_aligned("12 frames".to_string(), 11.0, theme.fg_placeholder, Alignment::End),
435        ]),
436        View::<()>::new(Style {
437            size: Size { width: percent(1.0), height: length(94.0) },
438            flex_direction: FlexDirection::Row,
439            align_items: Some(AlignItems::FLEX_END),
440            justify_content: Some(JustifyContent::SPACE_BETWEEN),
441            ..Default::default()
442        })
443        .children(barras),
444    ]);
445
446    // 3) Botones / chips — el acento del theme en acción.
447    let boton = |label: &str, primario: bool| {
448        let base = View::<()>::new(Style {
449            size: Size { width: auto(), height: length(34.0) },
450            align_items: Some(AlignItems::CENTER),
451            justify_content: Some(JustifyContent::CENTER),
452            padding: Rect { left: length(18.0), right: length(18.0), top: length(0.0), bottom: length(0.0) },
453            ..Default::default()
454        })
455        .radius(17.0);
456        if primario {
457            base.fill_gradient(
458                Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
459                    .with_stops([rgb(124, 154, 232), rgb(92, 120, 198)].as_slice()),
460            )
461            .shadow(Shadow::soft(90, 16.0).offset(0.0, 6.0))
462            .children(vec![txt(auto(), 18.0, label, 13.0, rgb(244, 247, 255)).bold()])
463        } else {
464            base.fill(theme.bg_button)
465                .border(1.0, theme.border)
466                .children(vec![txt(auto(), 18.0, label, 13.0, theme.fg_text)])
467        }
468    };
469    let acciones = View::<()>::new(Style {
470        size: Size { width: percent(1.0), height: length(34.0) },
471        flex_direction: FlexDirection::Row,
472        gap: Size { width: length(10.0), height: length(0.0) },
473        ..Default::default()
474    })
475    .children(vec![boton("Regenerar", true), boton("Difundir", false), boton("Alinear", false)]);
476
477    // 4) Tarjeta de hebras (estado del haz multilienzo) — filas con dot de estado.
478    let hebra = |de: &str, a: &str, estado: &str, c: Color| {
479        View::<()>::new(Style {
480            size: Size { width: percent(1.0), height: length(26.0) },
481            flex_direction: FlexDirection::Row,
482            align_items: Some(AlignItems::CENTER),
483            justify_content: Some(JustifyContent::SPACE_BETWEEN),
484            ..Default::default()
485        })
486        .children(vec![
487            View::<()>::new(Style {
488                flex_direction: FlexDirection::Row,
489                align_items: Some(AlignItems::CENTER),
490                gap: Size { width: length(8.0), height: length(0.0) },
491                ..Default::default()
492            })
493            .children(vec![
494                rect(7.0, 7.0).radius(3.5).fill(c),
495                txt(length(170.0), 16.0, &format!("{de} → {a}"), 12.0, theme.fg_text),
496            ]),
497            View::<()>::new(Style {
498                size: Size { width: length(80.0), height: length(15.0) },
499                ..Default::default()
500            })
501            .text_aligned(estado.to_string(), 11.0, c, Alignment::End),
502        ])
503    };
504    let hebras = View::<()>::new(Style {
505        size: Size { width: percent(1.0), height: length(148.0) },
506        flex_direction: FlexDirection::Column,
507        gap: Size { width: length(0.0), height: length(6.0) },
508        padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
509        ..Default::default()
510    })
511    .fill(theme.bg_panel)
512    .radius(12.0)
513    .border(1.0, theme.border)
514    .children(vec![
515        txt(percent(1.0), 18.0, "Hebras del haz", 13.0, theme.fg_text).bold(),
516        hebra("español", "english", "stale", rgb(229, 192, 123)),
517        hebra("español", "runasimi", "al día", rgb(152, 195, 121)),
518        hebra("español", "resumen", "al día", rgb(152, 195, 121)),
519        hebra("english", "tono formal", "derivando…", theme.accent),
520    ]);
521
522    let derecha = View::<()>::new(Style {
523        size: Size { width: length(330.0), height: percent(1.0) },
524        flex_shrink: 0.0,
525        flex_direction: FlexDirection::Column,
526        gap: Size { width: length(0.0), height: length(14.0) },
527        padding: Rect { left: length(0.0), right: length(14.0), top: length(14.0), bottom: length(14.0) },
528        ..Default::default()
529    })
530    .children(vec![hero, chart, acciones, hebras]);
531
532    // ───────────────────────── status bar ─────────────────────────
533    let status_item = |w: f32, s: &str, c: Color| txt(length(w), 16.0, s, 11.5, c);
534    let statusbar = View::<()>::new(Style {
535        size: Size { width: percent(1.0), height: length(30.0) },
536        flex_direction: FlexDirection::Row,
537        align_items: Some(AlignItems::CENTER),
538        gap: Size { width: length(18.0), height: length(0.0) },
539        padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
540        ..Default::default()
541    })
542    .fill(theme.bg_panel_alt)
543    .border(1.0, theme.border)
544    .children(vec![
545        status_item(70.0, "git · main", theme.accent),
546        status_item(250.0, "wgpu 27 · vello 0.7 · taffy · parley", theme.fg_muted),
547        status_item(80.0, "BLAKE3 ok", rgb(152, 195, 121)),
548        View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
549        status_item(45.0, "UTF-8", theme.fg_placeholder),
550        status_item(85.0, "Ln 7, Col 23", theme.fg_placeholder),
551        status_item(50.0, "100 Hz", theme.fg_placeholder),
552    ]);
553
554    // ─────────────── toast flotante (absoluto, esquinas asimétricas) ───────────────
555    let toast = View::<()>::new(Style {
556        position: Position::Absolute,
557        inset: Rect { left: auto(), top: auto(), right: length(360.0), bottom: length(48.0) },
558        size: Size { width: length(290.0), height: length(64.0) },
559        flex_direction: FlexDirection::Row,
560        align_items: Some(AlignItems::CENTER),
561        gap: Size { width: length(12.0), height: length(0.0) },
562        padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
563        ..Default::default()
564    })
565    .fill(rgb(28, 34, 48))
566    .radius_corners(18.0, 18.0, 18.0, 4.0)
567    .border(1.0, rgba(110, 140, 220, 160))
568    .shadow(Shadow::soft(150, 30.0).offset(0.0, 14.0))
569    .children(vec![
570        rect(34.0, 34.0)
571            .radius(10.0)
572            .fill_gradient(
573                Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
574                    .with_stops([rgb(120, 220, 200), rgb(60, 140, 170)].as_slice()),
575            ),
576        View::<()>::new(Style {
577            flex_direction: FlexDirection::Column,
578            gap: Size { width: length(0.0), height: length(2.0) },
579            ..Default::default()
580        })
581        .children(vec![
582            txt(auto(), 17.0, "Cuerpo regenerado", 13.0, theme.fg_text).bold(),
583            txt(auto(), 15.0, "english · 42 átomos realineados", 11.5, theme.fg_muted),
584        ]),
585    ]);
586
587    // ───────────────────────── árbol raíz ─────────────────────────
588    let fila_central = View::<()>::new(Style {
589        size: Size { width: percent(1.0), height: auto() },
590        flex_grow: 1.0,
591        flex_direction: FlexDirection::Row,
592        ..Default::default()
593    })
594    .children(vec![sidebar, centro, derecha]);
595
596    let root = View::<()>::new(Style {
597        size: Size { width: length(W as f32), height: length(H as f32) },
598        flex_direction: FlexDirection::Column,
599        ..Default::default()
600    })
601    .fill(theme.bg_app)
602    .children(vec![topbar, fila_central, statusbar, toast]);
603
604    // view → layout → scene → render headless → PNG (misma secuencia que el eventloop).
605    let mut layout = LayoutTree::new();
606    let mounted = mount(&mut layout, root);
607    let mut ts = Typesetter::new();
608    let computed = {
609        let tmap = &mounted.text_measures;
610        layout
611            .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
612                match tmap.get(&nid) {
613                    Some(tm) => measure_text_node(&mut ts, tm, known, avail),
614                    None => taffy::Size::ZERO,
615                }
616            })
617            .expect("layout")
618    };
619    let mut scene = vello::Scene::new();
620    paint(&mut scene, &mounted, &computed, &mut ts, None, None);
621
622    let hal = pollster::block_on(Hal::new(None)).expect("hal");
623    let mut renderer = Renderer::new(&hal).expect("renderer");
624    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
625        label: Some("pantallazo-motor"),
626        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
627        mip_level_count: 1,
628        sample_count: 1,
629        dimension: wgpu::TextureDimension::D2,
630        format: FMT,
631        usage: wgpu::TextureUsages::STORAGE_BINDING
632            | wgpu::TextureUsages::RENDER_ATTACHMENT
633            | wgpu::TextureUsages::COPY_SRC,
634        view_formats: &[],
635    });
636    let view = target.create_view(&wgpu::TextureViewDescriptor::default());
637    let [r, g, b, _] = theme.bg_app.components;
638    let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
639    renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
640
641    write_png(&hal, &target, &out);
642    eprintln!("pantallazo_motor: escrito {out} ({W}x{H})");
643}
644
645fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
646    let unpadded = (W * 4) as usize;
647    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
648    let padded = unpadded.div_ceil(align) * align;
649    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
650        label: Some("readback"),
651        size: (padded * H as usize) as u64,
652        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
653        mapped_at_creation: false,
654    });
655    let mut enc = hal
656        .device
657        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
658    enc.copy_texture_to_buffer(
659        wgpu::TexelCopyTextureInfo {
660            texture: target,
661            mip_level: 0,
662            origin: wgpu::Origin3d::ZERO,
663            aspect: wgpu::TextureAspect::All,
664        },
665        wgpu::TexelCopyBufferInfo {
666            buffer: &buf,
667            layout: wgpu::TexelCopyBufferLayout {
668                offset: 0,
669                bytes_per_row: Some(padded as u32),
670                rows_per_image: Some(H),
671            },
672        },
673        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
674    );
675    hal.queue.submit(std::iter::once(enc.finish()));
676    let slice = buf.slice(..);
677    let (tx, rx) = std::sync::mpsc::channel();
678    slice.map_async(wgpu::MapMode::Read, move |r| {
679        let _ = tx.send(r);
680    });
681    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
682    rx.recv().unwrap().unwrap();
683    let data = slice.get_mapped_range();
684    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
685    for row in 0..H as usize {
686        let s = row * padded;
687        pixels.extend_from_slice(&data[s..s + unpadded]);
688    }
689    drop(data);
690    buf.unmap();
691    let file = File::create(path).expect("png");
692    let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
693    enc.set_color(png::ColorType::Rgba);
694    enc.set_depth(png::BitDepth::Eight);
695    let mut w = enc.write_header().unwrap();
696    w.write_image_data(&pixels).unwrap();
697}