tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! Dump headless de la superficie de terminal **virtualizada** (Fase 1).
2//!
3//! Prueba la invariante central del SDD: **1 millón de líneas, costo de render
4//! constante**. Carga 1 M de renglones en el `Scrollback`, ancla el scroll al
5//! fondo (estilo terminal) y renderiza a PNG sólo la ventana visible. Imprime
6//! cuántas filas se materializaron (debe ser ~40, no un millón) — la evidencia
7//! exigida por el SDD: no afirmar paridad/eficiencia sin render + viewport
8//! medido.
9//!
10//! Uso: `cargo run -p llimphi-widget-terminal --example dump_terminal --release [out.png]`
11
12use std::fs::File;
13use std::io::BufWriter;
14
15use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint};
16use llimphi_ui::llimphi_hal::{wgpu, Hal};
17use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
18use llimphi_ui::llimphi_layout::{taffy, LayoutTree};
19use llimphi_ui::llimphi_raster::peniko::Color;
20use llimphi_ui::llimphi_raster::{vello, Renderer};
21use llimphi_ui::llimphi_text::{Alignment, Typesetter};
22use llimphi_ui::View;
23use llimphi_widget_terminal::{
24 line_surface, scroll_to_bottom, visible_window, LineStyle, Scrollback, TermMetrics,
25 TermPalette,
26};
27
28const W: u32 = 1100;
29const H: u32 = 720;
30const HEADER_H: f32 = 40.0;
31const TOTAL: usize = 1_000_000;
32const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
33
34fn main() {
35 let out = std::env::args()
36 .nth(1)
37 .unwrap_or_else(|| "terminal.png".to_string());
38 let theme = llimphi_theme::Theme::default();
39 let palette = TermPalette::from_theme(&theme);
40 let metrics = TermMetrics::for_font_size(13.0);
41
42 // 1 M de líneas — el scrollback "infinito". Sin cap (limit 0) para que las
43 // numere todas; ~30 MB de texto, acotado y O(1) por la Capa 0.
44 let mut store = Scrollback::new(0);
45 for i in 0..TOTAL {
46 store.push_line(&format!(
47 "fila {i:>7} :: lorem ipsum dolor sit amet, payload de salida del comando"
48 ));
49 }
50
51 let viewport_h = H as f32 - HEADER_H;
52 // Anclaje al fondo, como una terminal real tras un flood.
53 let scroll_y = scroll_to_bottom(store.len(), viewport_h, metrics.line_height);
54 let win = visible_window(store.len(), scroll_y, viewport_h, metrics.line_height);
55
56 // Coloreo semántico de muestra inyectado por el "caller": cada 9ª línea
57 // simula stderr (tinte rojo tenue + texto rojo), el resto tinta el prefijo
58 // "fila NNNNNNN" en acento — demuestra runs + bg sin que el widget sepa de
59 // comandos.
60 let accent = theme.accent;
61 let err_fg = theme.fg_destructive;
62 let err_bg = with_alpha(theme.fg_destructive, 0.14);
63 let line_style = move |idx: usize, text: &str| {
64 if idx % 9 == 0 {
65 LineStyle {
66 fg: Some(err_fg),
67 bg: Some(err_bg),
68 ..Default::default()
69 }
70 } else {
71 // Tinta el prefijo "fila NNNNNNN" (hasta el doble espacio).
72 let end = text.find(" ::").unwrap_or(0);
73 LineStyle {
74 runs: vec![(0, end, accent)],
75 ..Default::default()
76 }
77 }
78 };
79
80 let surface = line_surface::<(), _, _>(
81 &store,
82 scroll_y,
83 viewport_h,
84 metrics,
85 &palette,
86 line_style,
87 |_d| (),
88 None,
89 );
90
91 // Header con la evidencia: total vs. filas materializadas.
92 let header = View::new(Style {
93 size: Size {
94 width: percent(1.0_f32),
95 height: length(HEADER_H),
96 },
97 ..Default::default()
98 })
99 .fill(theme.bg_panel_alt)
100 .text_aligned(
101 format!(
102 "scrollback {TOTAL} líneas · materializadas {} (filas {}..{}) · costo constante",
103 win.count(),
104 win.first + 1,
105 win.last,
106 ),
107 13.0,
108 theme.fg_text,
109 Alignment::Start,
110 );
111
112 let root = View::new(Style {
113 flex_direction: FlexDirection::Column,
114 size: Size {
115 width: percent(1.0_f32),
116 height: percent(1.0_f32),
117 },
118 ..Default::default()
119 })
120 .fill(theme.bg_input)
121 .children(vec![header, surface]);
122
123 // Pipeline headless estándar (igual que los dumps de shuma).
124 let mut layout = LayoutTree::new();
125 let mounted = mount(&mut layout, root);
126 let mut ts = Typesetter::new();
127 let computed = {
128 let tmap = &mounted.text_measures;
129 layout
130 .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
131 match tmap.get(&nid) {
132 Some(tm) => measure_text_node(&mut ts, tm, known, avail),
133 None => taffy::Size::ZERO,
134 }
135 })
136 .expect("layout")
137 };
138 let mut scene = vello::Scene::new();
139 paint(&mut scene, &mounted, &computed, &mut ts, None, None);
140
141 let hal = pollster::block_on(Hal::new(None)).expect("hal");
142 let mut renderer = Renderer::new(&hal).expect("renderer");
143 let target = hal.device.create_texture(&wgpu::TextureDescriptor {
144 label: Some("dump-terminal"),
145 size: wgpu::Extent3d {
146 width: W,
147 height: H,
148 depth_or_array_layers: 1,
149 },
150 mip_level_count: 1,
151 sample_count: 1,
152 dimension: wgpu::TextureDimension::D2,
153 format: FMT,
154 usage: wgpu::TextureUsages::STORAGE_BINDING
155 | wgpu::TextureUsages::RENDER_ATTACHMENT
156 | wgpu::TextureUsages::COPY_SRC,
157 view_formats: &[],
158 });
159 let view = target.create_view(&wgpu::TextureViewDescriptor::default());
160 renderer
161 .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(18, 18, 24, 255))
162 .expect("render_to_view");
163 write_png(&hal, &target, &out);
164 eprintln!(
165 "dump_terminal: {out} ({W}x{H}) — {TOTAL} líneas, materializadas {} (filas {}..{})",
166 win.count(),
167 win.first + 1,
168 win.last,
169 );
170}
171
172/// Devuelve `c` con la opacidad multiplicada por `alpha`.
173fn with_alpha(c: Color, alpha: f32) -> Color {
174 let rgba = c.to_rgba8();
175 let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
176 Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
177}
178
179fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
180 let unpadded = (W * 4) as usize;
181 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
182 let padded = unpadded.div_ceil(align) * align;
183 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
184 label: Some("readback"),
185 size: (padded * H as usize) as u64,
186 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
187 mapped_at_creation: false,
188 });
189 let mut enc = hal
190 .device
191 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
192 enc.copy_texture_to_buffer(
193 wgpu::TexelCopyTextureInfo {
194 texture: target,
195 mip_level: 0,
196 origin: wgpu::Origin3d::ZERO,
197 aspect: wgpu::TextureAspect::All,
198 },
199 wgpu::TexelCopyBufferInfo {
200 buffer: &buf,
201 layout: wgpu::TexelCopyBufferLayout {
202 offset: 0,
203 bytes_per_row: Some(padded as u32),
204 rows_per_image: Some(H),
205 },
206 },
207 wgpu::Extent3d {
208 width: W,
209 height: H,
210 depth_or_array_layers: 1,
211 },
212 );
213 hal.queue.submit(std::iter::once(enc.finish()));
214 let slice = buf.slice(..);
215 let (tx, rx) = std::sync::mpsc::channel();
216 slice.map_async(wgpu::MapMode::Read, move |r| {
217 let _ = tx.send(r);
218 });
219 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
220 rx.recv().unwrap().unwrap();
221 let data = slice.get_mapped_range();
222 let mut pixels = Vec::with_capacity((W * H * 4) as usize);
223 for row in 0..H as usize {
224 let s = row * padded;
225 pixels.extend_from_slice(&data[s..s + unpadded]);
226 }
227 drop(data);
228 buf.unmap();
229 let file = File::create(path).expect("png");
230 let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
231 enc.set_color(png::ColorType::Rgba);
232 enc.set_depth(png::BitDepth::Eight);
233 let mut w = enc.write_header().unwrap();
234 w.write_image_data(&pixels).unwrap();
235}