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,32 @@
|
||||
[package]
|
||||
name = "llimphi-widget-terminal"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-widget-terminal — superficie de terminal infinita y virtualizada (ver 02_ruway/shuma/SDD-TERMINAL.md). Fase 0: store de scrollback append-only (acceso O(1), cap por memoria). Fase 1: virtualización modo línea (sólo se pinta la ventana visible, numeración global + color, scroll propio del widget — costo de render constante a scrollback ilimitado)."
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
llimphi-widget-scroll = { workspace = true }
|
||||
# Acceso a `wgpu` (re-exportado por `llimphi-hal`) para el pipeline GPU
|
||||
# del cell renderer (Fase 4 del SDD-TERMINAL).
|
||||
llimphi-hal = { path = "../../llimphi-hal" }
|
||||
# Rasterizador de glifos para el atlas GPU del modo grilla (Fase 4 del
|
||||
# SDD-TERMINAL). Default features de fontdue alcanzan acá (std host).
|
||||
fontdue = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
png = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "dump_terminal"
|
||||
path = "examples/dump_terminal.rs"
|
||||
|
||||
[[example]]
|
||||
name = "dump_blocks"
|
||||
path = "examples/dump_blocks.rs"
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Dump headless del **modelo de bloques** virtualizado (Fase 2).
|
||||
//!
|
||||
//! Arma un stream de comandos como en el shell: cada comando = un header de
|
||||
//! card (chrome de alto fijo que el "caller" pinta) + un body de líneas del
|
||||
//! store. Incluye:
|
||||
//! - comandos cortos (ls, cargo) con stderr tintado,
|
||||
//! - un comando **colapsado** (sólo header, sin body),
|
||||
//! - un **flood** de 500 000 líneas (find /) en el medio,
|
||||
//! y ancla el scroll al fondo. Prueba que la virtualización por bloques
|
||||
//! materializa sólo los items + sub-filas visibles (costo constante) aunque un
|
||||
//! body tenga medio millón de líneas.
|
||||
//!
|
||||
//! Uso: `cargo run -p llimphi-widget-terminal --example dump_blocks --release [out.png]`
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
|
||||
use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint};
|
||||
use llimphi_ui::llimphi_hal::{wgpu, Hal};
|
||||
use llimphi_ui::llimphi_layout::taffy::prelude::{
|
||||
auto, length, percent, AlignItems, FlexDirection, Rect, Size, Style,
|
||||
};
|
||||
use llimphi_ui::llimphi_layout::{taffy, LayoutTree};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::llimphi_raster::{vello, Renderer};
|
||||
use llimphi_ui::llimphi_text::{Alignment, Typesetter};
|
||||
use llimphi_ui::View;
|
||||
use llimphi_widget_terminal::{
|
||||
block_surface, blocks_height, blocks_scroll_to_bottom, visible_window, Item, LineStyle,
|
||||
Scrollback, TermMetrics, TermPalette,
|
||||
};
|
||||
|
||||
const W: u32 = 1100;
|
||||
const H: u32 = 760;
|
||||
const INFO_H: f32 = 36.0;
|
||||
const HEADER_H: f32 = 30.0;
|
||||
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
/// Un comando del stream: rango de body en el store + estado para el header.
|
||||
struct Cmd {
|
||||
text: String,
|
||||
exit: i32,
|
||||
start: usize,
|
||||
end: usize,
|
||||
collapsed: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let out = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "blocks.png".to_string());
|
||||
let theme = llimphi_theme::Theme::default();
|
||||
let palette = TermPalette::from_theme(&theme);
|
||||
let metrics = TermMetrics::for_font_size(13.0);
|
||||
|
||||
let mut store = Scrollback::new(0);
|
||||
let mut stderr: HashSet<usize> = HashSet::new();
|
||||
let mut cmds: Vec<Cmd> = Vec::new();
|
||||
|
||||
// Helper para registrar un comando con su body.
|
||||
let run = |store: &mut Scrollback,
|
||||
stderr: &mut HashSet<usize>,
|
||||
cmds: &mut Vec<Cmd>,
|
||||
text: &str,
|
||||
exit: i32,
|
||||
collapsed: bool,
|
||||
body: &[(bool, String)]| {
|
||||
let start = store.len();
|
||||
for (is_err, line) in body {
|
||||
if *is_err {
|
||||
stderr.insert(store.len());
|
||||
}
|
||||
store.push_line(line);
|
||||
}
|
||||
cmds.push(Cmd {
|
||||
text: text.to_string(),
|
||||
exit,
|
||||
start,
|
||||
end: store.len(),
|
||||
collapsed,
|
||||
});
|
||||
};
|
||||
|
||||
// 1) ls -la — salida normal corta.
|
||||
run(
|
||||
&mut store,
|
||||
&mut stderr,
|
||||
&mut cmds,
|
||||
"ls -la ~/tawasuyu",
|
||||
0,
|
||||
false,
|
||||
&[
|
||||
(false, "total 248".into()),
|
||||
(false, "drwxr-xr-x 12 sergio sergio 4096 jun 6 00_unanchay".into()),
|
||||
(false, "drwxr-xr-x 8 sergio sergio 4096 jun 6 02_ruway".into()),
|
||||
(false, "-rw-r--r-- 1 sergio sergio 11234 jun 6 CLAUDE.md".into()),
|
||||
(false, "-rw-r--r-- 1 sergio sergio 8901 jun 6 README.md".into()),
|
||||
],
|
||||
);
|
||||
|
||||
// 2) cargo build — con un warning a stderr.
|
||||
run(
|
||||
&mut store,
|
||||
&mut stderr,
|
||||
&mut cmds,
|
||||
"cargo build -p llimphi-widget-terminal",
|
||||
0,
|
||||
false,
|
||||
&[
|
||||
(false, " Compiling llimphi-widget-terminal v0.1.0".into()),
|
||||
(true, "warning: unused variable `x`".into()),
|
||||
(true, " --> src/blocks.rs:42:9".into()),
|
||||
(false, " Finished `dev` profile in 1.89s".into()),
|
||||
],
|
||||
);
|
||||
|
||||
// 3) find / — el FLOOD: medio millón de líneas en un solo body.
|
||||
{
|
||||
let start = store.len();
|
||||
for i in 0..500_000 {
|
||||
store.push_line(&format!(
|
||||
"/home/sergio/tawasuyu/02_ruway/llimphi/widgets/terminal/src/archivo_{i:06}.rs"
|
||||
));
|
||||
}
|
||||
cmds.push(Cmd {
|
||||
text: "find / -name '*.rs'".into(),
|
||||
exit: 0,
|
||||
start,
|
||||
end: store.len(),
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 4) git status — COLAPSADO (sólo header, body oculto).
|
||||
run(
|
||||
&mut store,
|
||||
&mut stderr,
|
||||
&mut cmds,
|
||||
"git status",
|
||||
0,
|
||||
true,
|
||||
&[
|
||||
(false, "On branch main".into()),
|
||||
(false, "Changes not staged for commit:".into()),
|
||||
(false, " modified: src/blocks.rs".into()),
|
||||
],
|
||||
);
|
||||
|
||||
// 5) cat inexistente — exit 1, stderr.
|
||||
run(
|
||||
&mut store,
|
||||
&mut stderr,
|
||||
&mut cmds,
|
||||
"cat noexiste.txt",
|
||||
1,
|
||||
false,
|
||||
&[(true, "cat: noexiste.txt: No such file or directory".into())],
|
||||
);
|
||||
|
||||
// 6) echo final corto.
|
||||
run(
|
||||
&mut store,
|
||||
&mut stderr,
|
||||
&mut cmds,
|
||||
"echo listo",
|
||||
0,
|
||||
false,
|
||||
&[(false, "listo".into())],
|
||||
);
|
||||
|
||||
// Construye los items: por cada comando, un header (chrome) y, salvo
|
||||
// colapsado, su body (Lines). El widget virtualiza sobre estas alturas.
|
||||
let mut items: Vec<Item<()>> = Vec::new();
|
||||
for c in &cmds {
|
||||
items.push(Item::chrome(HEADER_H, header_card(c, &theme)));
|
||||
if !c.collapsed {
|
||||
items.push(Item::lines(c.start, c.end));
|
||||
}
|
||||
}
|
||||
|
||||
let viewport_h = H as f32 - INFO_H;
|
||||
let row_h = metrics.line_height;
|
||||
let scroll_y = blocks_scroll_to_bottom(&items, viewport_h, row_h);
|
||||
|
||||
// Coloreo inyectado por el caller: stderr rojo (texto + tinte), resto tinta
|
||||
// el prefijo hasta el primer espacio en acento (paths/tokens).
|
||||
let accent = theme.accent;
|
||||
let err_fg = theme.fg_destructive;
|
||||
let err_bg = with_alpha(theme.fg_destructive, 0.14);
|
||||
let line_style = move |idx: usize, text: &str| {
|
||||
if stderr.contains(&idx) {
|
||||
LineStyle {
|
||||
fg: Some(err_fg),
|
||||
bg: Some(err_bg),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
let end = text.find(' ').unwrap_or(text.len());
|
||||
LineStyle {
|
||||
runs: vec![(0, end, accent)],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Evidencia: cuántos items/filas se materializaron del total.
|
||||
let total_lines = store.len();
|
||||
let total_h = blocks_height(&items, row_h);
|
||||
// Filas visibles ~ las que entran en el viewport (medida de costo).
|
||||
let approx_rows = visible_window(total_lines, scroll_y, viewport_h, row_h).count();
|
||||
let info = format!(
|
||||
"{} comandos · {} líneas en scrollback · alto virtual {:.0}px · ~{} filas materializadas · anclado al fondo",
|
||||
cmds.len(),
|
||||
total_lines,
|
||||
total_h,
|
||||
approx_rows,
|
||||
);
|
||||
|
||||
let surface = block_surface::<(), _, _>(
|
||||
&store,
|
||||
items,
|
||||
scroll_y,
|
||||
viewport_h,
|
||||
metrics,
|
||||
&palette,
|
||||
line_style,
|
||||
|_d| (),
|
||||
None,
|
||||
);
|
||||
|
||||
let info_bar = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(INFO_H),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_panel_alt)
|
||||
.text_aligned(info, 12.5, theme.fg_text, Alignment::Start);
|
||||
|
||||
let root = View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_input)
|
||||
.children(vec![info_bar, surface]);
|
||||
|
||||
render_png(root, &out);
|
||||
eprintln!("dump_blocks: {out} ({W}x{H}) — {total_lines} líneas, ~{approx_rows} filas materializadas");
|
||||
}
|
||||
|
||||
/// Header de card de un comando — el "chrome" que el caller pinta: barra de
|
||||
/// acento a la izquierda + `$ comando` mono + estado `exit N` a la derecha.
|
||||
fn header_card(c: &Cmd, theme: &llimphi_theme::Theme) -> View<()> {
|
||||
let (status_txt, status_col) = if c.exit == 0 {
|
||||
(format!("exit {}", c.exit), Color::from_rgb8(120, 200, 130))
|
||||
} else {
|
||||
(format!("exit {}", c.exit), theme.fg_destructive)
|
||||
};
|
||||
// ASCII a propósito: la mono embebida no cubre triángulos geométricos
|
||||
// (tofu) — `+` colapsado / `-` expandido, convención de árbol.
|
||||
let chevron = if c.collapsed { "+" } else { "-" };
|
||||
|
||||
// Barra de acento (3px) a la izquierda.
|
||||
let accent_bar = View::new(Style {
|
||||
size: Size {
|
||||
width: length(3.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(if c.exit == 0 { theme.accent } else { theme.fg_destructive });
|
||||
|
||||
// Estado (alineado a la derecha): chevron de colapso + `exit N`.
|
||||
let status = View::new(Style {
|
||||
size: Size {
|
||||
width: length(140.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(
|
||||
format!("{chevron} {status_txt}"),
|
||||
12.0,
|
||||
status_col,
|
||||
Alignment::End,
|
||||
)
|
||||
.mono();
|
||||
|
||||
let cmd = View::new(Style {
|
||||
flex_grow: 1.0,
|
||||
size: Size {
|
||||
width: auto(),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
padding: Rect {
|
||||
left: length(8.0_f32),
|
||||
right: length(8.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(format!("$ {}", c.text), 13.0, theme.fg_text, Alignment::Start)
|
||||
.mono();
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(HEADER_H),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_panel)
|
||||
.children(vec![accent_bar, cmd, status])
|
||||
}
|
||||
|
||||
/// Devuelve `c` con la opacidad fijada a `alpha`.
|
||||
fn with_alpha(c: Color, alpha: f32) -> Color {
|
||||
let rgba = c.to_rgba8();
|
||||
Color::from_rgba8(rgba.r, rgba.g, rgba.b, (alpha.clamp(0.0, 1.0) * 255.0) as u8)
|
||||
}
|
||||
|
||||
fn render_png(root: View<()>, out: &str) {
|
||||
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-blocks"),
|
||||
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());
|
||||
renderer
|
||||
.render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(18, 18, 24, 255))
|
||||
.expect("render_to_view");
|
||||
write_png(&hal, &target, out);
|
||||
}
|
||||
|
||||
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,235 @@
|
||||
//! Dump headless de la superficie de terminal **virtualizada** (Fase 1).
|
||||
//!
|
||||
//! Prueba la invariante central del SDD: **1 millón de líneas, costo de render
|
||||
//! constante**. Carga 1 M de renglones en el `Scrollback`, ancla el scroll al
|
||||
//! fondo (estilo terminal) y renderiza a PNG sólo la ventana visible. Imprime
|
||||
//! cuántas filas se materializaron (debe ser ~40, no un millón) — la evidencia
|
||||
//! exigida por el SDD: no afirmar paridad/eficiencia sin render + viewport
|
||||
//! medido.
|
||||
//!
|
||||
//! Uso: `cargo run -p llimphi-widget-terminal --example dump_terminal --release [out.png]`
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
|
||||
use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint};
|
||||
use llimphi_ui::llimphi_hal::{wgpu, Hal};
|
||||
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
|
||||
use llimphi_ui::llimphi_layout::{taffy, LayoutTree};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::llimphi_raster::{vello, Renderer};
|
||||
use llimphi_ui::llimphi_text::{Alignment, Typesetter};
|
||||
use llimphi_ui::View;
|
||||
use llimphi_widget_terminal::{
|
||||
line_surface, scroll_to_bottom, visible_window, LineStyle, Scrollback, TermMetrics,
|
||||
TermPalette,
|
||||
};
|
||||
|
||||
const W: u32 = 1100;
|
||||
const H: u32 = 720;
|
||||
const HEADER_H: f32 = 40.0;
|
||||
const TOTAL: usize = 1_000_000;
|
||||
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
fn main() {
|
||||
let out = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "terminal.png".to_string());
|
||||
let theme = llimphi_theme::Theme::default();
|
||||
let palette = TermPalette::from_theme(&theme);
|
||||
let metrics = TermMetrics::for_font_size(13.0);
|
||||
|
||||
// 1 M de líneas — el scrollback "infinito". Sin cap (limit 0) para que las
|
||||
// numere todas; ~30 MB de texto, acotado y O(1) por la Capa 0.
|
||||
let mut store = Scrollback::new(0);
|
||||
for i in 0..TOTAL {
|
||||
store.push_line(&format!(
|
||||
"fila {i:>7} :: lorem ipsum dolor sit amet, payload de salida del comando"
|
||||
));
|
||||
}
|
||||
|
||||
let viewport_h = H as f32 - HEADER_H;
|
||||
// Anclaje al fondo, como una terminal real tras un flood.
|
||||
let scroll_y = scroll_to_bottom(store.len(), viewport_h, metrics.line_height);
|
||||
let win = visible_window(store.len(), scroll_y, viewport_h, metrics.line_height);
|
||||
|
||||
// Coloreo semántico de muestra inyectado por el "caller": cada 9ª línea
|
||||
// simula stderr (tinte rojo tenue + texto rojo), el resto tinta el prefijo
|
||||
// "fila NNNNNNN" en acento — demuestra runs + bg sin que el widget sepa de
|
||||
// comandos.
|
||||
let accent = theme.accent;
|
||||
let err_fg = theme.fg_destructive;
|
||||
let err_bg = with_alpha(theme.fg_destructive, 0.14);
|
||||
let line_style = move |idx: usize, text: &str| {
|
||||
if idx % 9 == 0 {
|
||||
LineStyle {
|
||||
fg: Some(err_fg),
|
||||
bg: Some(err_bg),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
// Tinta el prefijo "fila NNNNNNN" (hasta el doble espacio).
|
||||
let end = text.find(" ::").unwrap_or(0);
|
||||
LineStyle {
|
||||
runs: vec![(0, end, accent)],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let surface = line_surface::<(), _, _>(
|
||||
&store,
|
||||
scroll_y,
|
||||
viewport_h,
|
||||
metrics,
|
||||
&palette,
|
||||
line_style,
|
||||
|_d| (),
|
||||
None,
|
||||
);
|
||||
|
||||
// Header con la evidencia: total vs. filas materializadas.
|
||||
let header = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(HEADER_H),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_panel_alt)
|
||||
.text_aligned(
|
||||
format!(
|
||||
"scrollback {TOTAL} líneas · materializadas {} (filas {}..{}) · costo constante",
|
||||
win.count(),
|
||||
win.first + 1,
|
||||
win.last,
|
||||
),
|
||||
13.0,
|
||||
theme.fg_text,
|
||||
Alignment::Start,
|
||||
);
|
||||
|
||||
let root = View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(theme.bg_input)
|
||||
.children(vec![header, surface]);
|
||||
|
||||
// Pipeline headless estándar (igual que los dumps de shuma).
|
||||
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-terminal"),
|
||||
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());
|
||||
renderer
|
||||
.render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(18, 18, 24, 255))
|
||||
.expect("render_to_view");
|
||||
write_png(&hal, &target, &out);
|
||||
eprintln!(
|
||||
"dump_terminal: {out} ({W}x{H}) — {TOTAL} líneas, materializadas {} (filas {}..{})",
|
||||
win.count(),
|
||||
win.first + 1,
|
||||
win.last,
|
||||
);
|
||||
}
|
||||
|
||||
/// Devuelve `c` con la opacidad multiplicada por `alpha`.
|
||||
fn with_alpha(c: Color, alpha: f32) -> Color {
|
||||
let rgba = c.to_rgba8();
|
||||
let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
|
||||
Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
|
||||
}
|
||||
|
||||
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,790 @@
|
||||
//! Modelo de bloques + chrome virtualizado (Capa 1 del SDD-TERMINAL, Fase 2).
|
||||
//!
|
||||
//! El stream no es una lista plana de líneas: es una secuencia de **bloques**.
|
||||
//! Para shuma un bloque = un comando (header `$ …` + cuerpo + badge + filas de
|
||||
//! etapa + estado colapsado). El widget no sabe de comandos (Regla 2): modela
|
||||
//! [`Item`]s heterogéneos —
|
||||
//!
|
||||
//! - [`Item::Chrome`] — un nodo opaco de **alto fijo** que el caller pinta
|
||||
//! (header de card, fila de etapas, badge). El widget sólo lo **ubica**.
|
||||
//! - [`Item::Lines`] — un rango `[start, end)` de líneas del store en modo
|
||||
//! línea (numeradas/coloreadas). **Colapsar** un bloque = no emitir su item
|
||||
//! `Lines` (o emitirlo con `start == end`): la virtualización lo respeta gratis.
|
||||
//!
|
||||
//! [`block_surface`] virtualiza sobre **alturas mixtas**: localiza por búsqueda
|
||||
//! binaria los items que tocan el viewport y, dentro de un `Lines` enorme,
|
||||
//! materializa **sólo** las sub-filas visibles. Costo de render constante aunque
|
||||
//! un body tenga millones de líneas. El modo línea de la Fase 1
|
||||
//! ([`crate::view::line_surface`]) es el caso de **un solo** `Item::Lines` que
|
||||
//! cubre todo el store.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::prelude::{auto, length, percent, Position, Rect, Size, Style};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::llimphi_text::Alignment;
|
||||
use llimphi_ui::{DragPhase, PaintRect, View};
|
||||
use llimphi_widget_scroll::{max_offset, thumb_geometry, DEFAULT_LINE_PX};
|
||||
|
||||
use crate::store::Scrollback;
|
||||
use crate::select::{selection_rects, SelectionRange};
|
||||
use crate::view::{LineStyle, TermMetrics, TermPalette};
|
||||
|
||||
/// Ancho de la barra de scroll, en px.
|
||||
const BAR_WIDTH: f32 = 10.0;
|
||||
/// Alto mínimo del thumb, en px (para que no desaparezca con scrollback enorme).
|
||||
const MIN_THUMB: f32 = 28.0;
|
||||
|
||||
/// Un item del stream virtualizado: chrome opaco (alto fijo, lo pinta el caller)
|
||||
/// o un rango de líneas del store (modo línea).
|
||||
pub enum Item<Msg> {
|
||||
/// Chrome de alto fijo provisto por el caller — header de card, fila de
|
||||
/// etapas, badge. El widget lo ubica en su `top` y recorta lo que sobra.
|
||||
Chrome { height: f32, view: View<Msg> },
|
||||
/// Rango `[start, end)` de líneas del store (índices 0-based vigentes).
|
||||
Lines { start: usize, end: usize },
|
||||
}
|
||||
|
||||
impl<Msg> Item<Msg> {
|
||||
/// Chrome de alto fijo (header, etapa, badge…).
|
||||
pub fn chrome(height: f32, view: View<Msg>) -> Self {
|
||||
Self::Chrome { height, view }
|
||||
}
|
||||
|
||||
/// Filas del store `[start, end)`.
|
||||
pub fn lines(start: usize, end: usize) -> Self {
|
||||
Self::Lines { start, end }
|
||||
}
|
||||
|
||||
/// Alto del item en px (chrome: el suyo; lines: filas × alto_fila).
|
||||
pub fn height(&self, row_h: f32) -> f32 {
|
||||
match self {
|
||||
Self::Chrome { height, .. } => *height,
|
||||
Self::Lines { start, end } => end.saturating_sub(*start) as f32 * row_h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometría liviana (sin la `View` del chrome) para hit-tests fuera del
|
||||
/// render. Es `Copy`, así que se puede stashear en un `Mutex` para que el
|
||||
/// `update` resuelva clicks contra el layout del frame anterior.
|
||||
pub fn geo(&self) -> ItemGeo {
|
||||
match self {
|
||||
Self::Chrome { height, .. } => ItemGeo::Chrome(*height),
|
||||
Self::Lines { start, end } => ItemGeo::Lines(*start, *end),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Variante liviana y `Copy` de [`Item`] sin la `View` — sólo lo necesario
|
||||
/// para resolver hit-tests por (lx, ly). Se obtiene con [`Item::geo`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ItemGeo {
|
||||
Chrome(f32),
|
||||
Lines(usize, usize),
|
||||
}
|
||||
|
||||
impl ItemGeo {
|
||||
/// Alto del item en px, mismo cálculo que [`Item::height`].
|
||||
pub fn height(&self, row_h: f32) -> f32 {
|
||||
match self {
|
||||
Self::Chrome(h) => *h,
|
||||
Self::Lines(s, e) => e.saturating_sub(*s) as f32 * row_h,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tops acumulados (content coords) de cada item dados sus altos, y el alto
|
||||
/// total. `tops[i]` = `y` del item `i`; el total cierra el contenido. **Puro**.
|
||||
pub fn item_tops(heights: &[f32]) -> (Vec<f32>, f32) {
|
||||
let mut tops = Vec::with_capacity(heights.len());
|
||||
let mut acc = 0.0;
|
||||
for &h in heights {
|
||||
tops.push(acc);
|
||||
acc += h;
|
||||
}
|
||||
(tops, acc)
|
||||
}
|
||||
|
||||
/// Rango `[first, last)` de items que **intersectan** el viewport `[off, off+vp)`
|
||||
/// bajo `scroll_y` (clampeado a `[0, total-vp]`). `tops` es monótono → búsqueda
|
||||
/// binaria, O(log n) en la cantidad de bloques. **Puro**.
|
||||
pub fn visible_items(tops: &[f32], total: f32, scroll_y: f32, viewport_h: f32) -> (usize, usize) {
|
||||
let n = tops.len();
|
||||
if n == 0 || viewport_h <= 0.0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let off = scroll_y.clamp(0.0, (total - viewport_h).max(0.0));
|
||||
// Primer item visible = el que contiene `off` (último top ≤ off). Como
|
||||
// `tops[0] == 0 ≤ off`, el prefijo de tops ≤ off es no vacío.
|
||||
let first = tops.partition_point(|&t| t <= off).saturating_sub(1);
|
||||
// Último visible = primer item cuyo top ya cae fuera del fondo del viewport.
|
||||
let last = tops.partition_point(|&t| t < off + viewport_h);
|
||||
(first, last.max(first + 1).min(n))
|
||||
}
|
||||
|
||||
/// Sub-filas locales `[k0, k1)` de un item `Lines` de `nrows` filas cuyo `top`
|
||||
/// (content coords) materializan dentro del viewport `[off, off+vp)`. **Puro**.
|
||||
fn visible_rows_in_item(top: f32, nrows: usize, off: f32, vp: f32, row_h: f32) -> (usize, usize) {
|
||||
if nrows == 0 || row_h <= 0.0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let k0 = (((off - top) / row_h).floor().max(0.0) as usize).min(nrows);
|
||||
let k1 = (((off + vp - top) / row_h).ceil().max(0.0) as usize).min(nrows);
|
||||
(k0, k1.max(k0))
|
||||
}
|
||||
|
||||
/// Alto total del contenido de una lista de items, en px.
|
||||
pub fn blocks_height<Msg>(items: &[Item<Msg>], row_h: f32) -> f32 {
|
||||
items.iter().map(|it| it.height(row_h)).sum()
|
||||
}
|
||||
|
||||
/// El `scroll_y` que ancla el stream **al fondo** (estilo terminal): el máximo
|
||||
/// offset posible dada la altura total de los bloques.
|
||||
pub fn blocks_scroll_to_bottom<Msg>(items: &[Item<Msg>], viewport_h: f32, row_h: f32) -> f32 {
|
||||
max_offset(blocks_height(items, row_h), viewport_h)
|
||||
}
|
||||
|
||||
/// Superficie de terminal **por bloques, virtualizada** (Capa 1–2).
|
||||
///
|
||||
/// Materializa sólo los items —y dentro de un `Lines`, sólo las sub-filas— que
|
||||
/// caen en el viewport bajo `scroll_y` (px). Costo de render **constante**
|
||||
/// respecto del scrollback y del tamaño de cada body. `on_scroll(delta_px)`,
|
||||
/// `line_style` y `measure` son como en [`crate::view::line_surface`].
|
||||
///
|
||||
/// El caller construye **todos** los chrome `View`s (los headers de card); sólo
|
||||
/// los visibles se pintan (los demás se descartan). Para cientos de bloques —el
|
||||
/// caso de un shell— es trivial; las **líneas** (los millones) sí se virtualizan
|
||||
/// de raíz.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn block_surface<Msg, S, F>(
|
||||
store: &Scrollback,
|
||||
items: Vec<Item<Msg>>,
|
||||
scroll_y: f32,
|
||||
viewport_h: f32,
|
||||
metrics: TermMetrics,
|
||||
palette: &TermPalette,
|
||||
line_style: S,
|
||||
on_scroll: F,
|
||||
measure: Option<Arc<Mutex<f32>>>,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + 'static,
|
||||
S: Fn(usize, &str) -> LineStyle,
|
||||
F: Fn(f32) -> Msg + Send + Sync + 'static,
|
||||
{
|
||||
block_surface_with_selection(
|
||||
store,
|
||||
items,
|
||||
scroll_y,
|
||||
viewport_h,
|
||||
metrics,
|
||||
palette,
|
||||
line_style,
|
||||
on_scroll,
|
||||
measure,
|
||||
SelectionConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Configura el cableado de selección sobre el `block_surface`. Empaqueta el
|
||||
/// rango actual (para pintar el overlay) y un handler de drag (para que el
|
||||
/// caller traduzca cada `(DragPhase, lx0, ly0, dx, dy)` del viewport en
|
||||
/// `Msg`s que actualicen su estado de selección).
|
||||
///
|
||||
/// Diseño en dos partes para mantener el control puro (Regla 2): el widget
|
||||
/// no toca el modelo, sólo pinta el rango que el caller le pasa y dispara
|
||||
/// callbacks; el caller arma la `SelectionRange` con [`crate::point_at`] y
|
||||
/// las acumula en su `Model`.
|
||||
pub struct SelectionConfig<'a, Msg> {
|
||||
/// Rango vigente — si está, se pinta como overlay translúcido.
|
||||
pub range: Option<&'a SelectionRange>,
|
||||
/// Handler de drag del cuerpo de la superficie. Se ata al viewport con
|
||||
/// `draggable_at` (gana sobre el `on_click` global del padre). `None`
|
||||
/// = la superficie no es seleccionable por mouse (sólo pinta).
|
||||
pub on_drag: Option<Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>>,
|
||||
/// Handler de doble-click. Recibe `(lx, ly, rect_w, rect_h)` del
|
||||
/// viewport. El caller lo resuelve a una palabra y la selecciona —
|
||||
/// paridad con la UX clásica de terminal (double-click select-word).
|
||||
pub on_double_click: Option<Arc<dyn Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl<Msg> Default for SelectionConfig<'_, Msg> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
range: None,
|
||||
on_drag: None,
|
||||
on_double_click: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Msg> SelectionConfig<'a, Msg> {
|
||||
/// Sólo pinta el rango (sin cableado de mouse).
|
||||
pub fn painted(range: &'a SelectionRange) -> Self {
|
||||
Self {
|
||||
range: Some(range),
|
||||
on_drag: None,
|
||||
on_double_click: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Como [`block_surface`], pero acepta una [`SelectionConfig`] para pintar el
|
||||
/// overlay de selección y/o cablear el drag del mouse al `Msg` del caller.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn block_surface_with_selection<Msg, S, F>(
|
||||
store: &Scrollback,
|
||||
items: Vec<Item<Msg>>,
|
||||
scroll_y: f32,
|
||||
viewport_h: f32,
|
||||
metrics: TermMetrics,
|
||||
palette: &TermPalette,
|
||||
line_style: S,
|
||||
on_scroll: F,
|
||||
measure: Option<Arc<Mutex<f32>>>,
|
||||
selection: SelectionConfig<'_, Msg>,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + 'static,
|
||||
S: Fn(usize, &str) -> LineStyle,
|
||||
F: Fn(f32) -> Msg + Send + Sync + 'static,
|
||||
{
|
||||
block_surface_with_scroll(
|
||||
store, items, scroll_y, 0.0, viewport_h, metrics, palette, line_style,
|
||||
on_scroll, measure, selection,
|
||||
)
|
||||
}
|
||||
|
||||
/// Variante con `scroll_x` adicional — el texto se desplaza horizontalmente
|
||||
/// (gutter queda fijo). Pensado para acomodar zoom-in que desborda. El
|
||||
/// caller actualiza `scroll_x` con Shift+rueda o atajos custom.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn block_surface_with_scroll<Msg, S, F>(
|
||||
store: &Scrollback,
|
||||
items: Vec<Item<Msg>>,
|
||||
scroll_y: f32,
|
||||
scroll_x: f32,
|
||||
viewport_h: f32,
|
||||
metrics: TermMetrics,
|
||||
palette: &TermPalette,
|
||||
line_style: S,
|
||||
on_scroll: F,
|
||||
measure: Option<Arc<Mutex<f32>>>,
|
||||
selection: SelectionConfig<'_, Msg>,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + 'static,
|
||||
S: Fn(usize, &str) -> LineStyle,
|
||||
F: Fn(f32) -> Msg + Send + Sync + 'static,
|
||||
{
|
||||
let row_h = metrics.line_height;
|
||||
let gw = gutter_width(store, metrics);
|
||||
let heights: Vec<f32> = items.iter().map(|it| it.height(row_h)).collect();
|
||||
let (tops, total) = item_tops(&heights);
|
||||
let off = scroll_y.clamp(0.0, max_offset(total, viewport_h));
|
||||
let (first, last) = visible_items(&tops, total, off, viewport_h);
|
||||
|
||||
// Highlight de selección: precomputado contra `&items` antes de consumirlos
|
||||
// en la iteración. La pintada va DESPUÉS del texto para que se vea encima
|
||||
// (alpha de la paleta) y ANTES del scrollbar.
|
||||
let sel_rects = match selection.range {
|
||||
Some(sel) if !sel.is_empty() => {
|
||||
selection_rects(&items, off, viewport_h, metrics, gw, store, sel)
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// Hijos absolutos en coords de viewport (content - off). Sólo los items
|
||||
// visibles y, dentro de un Lines, sólo sus sub-filas visibles.
|
||||
let mut children: Vec<View<Msg>> = Vec::new();
|
||||
|
||||
for (i, item) in items.into_iter().enumerate() {
|
||||
if i < first || i >= last {
|
||||
// Fuera de la ventana: el View de chrome se descarta acá (cheap).
|
||||
continue;
|
||||
}
|
||||
let top = tops[i];
|
||||
match item {
|
||||
Item::Chrome { height, view } => {
|
||||
children.push(
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
top: length(top - off),
|
||||
left: length(0.0_f32),
|
||||
right: length(0.0_f32),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(height),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![view]),
|
||||
);
|
||||
}
|
||||
Item::Lines { start, end } => {
|
||||
let nrows = end.saturating_sub(start);
|
||||
if nrows == 0 {
|
||||
continue;
|
||||
}
|
||||
let item_h = nrows as f32 * row_h;
|
||||
// Tira de gutter del bloque, recortada al tramo visible (evita
|
||||
// coords gigantes con bodies de millones de px de alto).
|
||||
let vis_top = top.max(off);
|
||||
let vis_bot = (top + item_h).min(off + viewport_h);
|
||||
if vis_bot > vis_top {
|
||||
children.push(gutter_bg(vis_top - off, vis_bot - vis_top, gw, palette));
|
||||
}
|
||||
let (k0, k1) = visible_rows_in_item(top, nrows, off, viewport_h, row_h);
|
||||
for k in k0..k1 {
|
||||
let idx = start + k;
|
||||
let y = top + k as f32 * row_h - off;
|
||||
let text = store.line(idx).unwrap_or("");
|
||||
let style = line_style(idx, text);
|
||||
if let Some(bg) = style.bg {
|
||||
children.push(row_tint(y, row_h, gw, bg));
|
||||
}
|
||||
children.push(gutter_number(store.line_number(idx), y, gw, row_h, metrics, palette));
|
||||
let fg = style.fg.unwrap_or(palette.fg_text);
|
||||
let runs = clamp_runs(style.runs, text.len());
|
||||
children.push(text_row_with_offset(text, y, gw, row_h, fg, runs, metrics, scroll_x));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay del highlight de selección — encima del texto, debajo del
|
||||
// scrollbar. Translúcido (alpha en `palette.bg_selection`) para no tapar
|
||||
// los glifos.
|
||||
for r in &sel_rects {
|
||||
children.push(selection_overlay_rect::<Msg>(*r, palette.bg_selection));
|
||||
}
|
||||
|
||||
let on_wheel = Arc::new(on_scroll);
|
||||
if max_offset(total, viewport_h) > 0.0 {
|
||||
children.push(scrollbar(off, total, viewport_h, palette, &on_wheel));
|
||||
}
|
||||
|
||||
// Viewport: alto fijo, contenido recortado, rueda local. Relative para
|
||||
// contener los hijos absolutos; painter de medición opcional (patrón shell).
|
||||
let on_wheel_view = Arc::clone(&on_wheel);
|
||||
let mut viewport = View::new(Style {
|
||||
position: Position::Relative,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(viewport_h),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg)
|
||||
.clip(true)
|
||||
.on_scroll(move |_dx, dy| Some((on_wheel_view)(dy * DEFAULT_LINE_PX)))
|
||||
.children(children);
|
||||
|
||||
if let Some(slot) = measure {
|
||||
viewport = viewport.paint_with(move |_scene, _ts, rect: PaintRect| {
|
||||
if let Ok(mut g) = slot.lock() {
|
||||
*g = rect.h;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-to-select: forwardea cada `(DragPhase, lx0, ly0, dx, dy)` del
|
||||
// viewport al handler del caller. El caller mantiene el `SelectionRange`
|
||||
// en su `Model` y usa `crate::point_at` para mapear (lx, ly) → `Point`.
|
||||
if let Some(on_drag) = selection.on_drag {
|
||||
viewport = viewport.draggable_at(move |phase, dx, dy, lx0, ly0| {
|
||||
(on_drag)(phase, lx0, ly0, dx, dy)
|
||||
});
|
||||
}
|
||||
// Doble-click: paridad con terminales clásicas (select-word). El caller
|
||||
// resuelve `(lx, ly)` a `Point` con `point_at_geo` + computa los
|
||||
// boundaries de palabra y emite un `Msg` que actualiza `surf_selection`.
|
||||
if let Some(on_double) = selection.on_double_click {
|
||||
viewport = viewport.on_double_tap_at(move |lx, ly, rect_w, rect_h| {
|
||||
(on_double)(lx, ly, rect_w, rect_h)
|
||||
});
|
||||
}
|
||||
|
||||
viewport
|
||||
}
|
||||
|
||||
// ── Builders de nodos por fila (compartidos con la Fase 1) ──────────────────
|
||||
|
||||
/// Tira de fondo del gutter de un bloque: rect a la izquierda, ancho `gw`.
|
||||
fn gutter_bg<Msg: Clone + 'static>(y: f32, h: f32, gw: f32, palette: &TermPalette) -> View<Msg> {
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
top: length(y),
|
||||
left: length(0.0_f32),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: length(gw),
|
||||
height: length(h),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg_gutter)
|
||||
}
|
||||
|
||||
/// Rect translúcido del overlay de selección — coords ya en viewport
|
||||
/// (scroll descontado por `selection_rects`). Va sobre el texto, sin
|
||||
/// recolorearlo (alpha del color del caller).
|
||||
fn selection_overlay_rect<Msg: Clone + 'static>(
|
||||
r: crate::select::HighlightRect,
|
||||
bg: Color,
|
||||
) -> View<Msg> {
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
top: length(r.y),
|
||||
left: length(r.x),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: length(r.w),
|
||||
height: length(r.h),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(bg)
|
||||
}
|
||||
|
||||
/// Tinte de fondo de un renglón (stderr, etc.), del gutter hacia la derecha.
|
||||
fn row_tint<Msg: Clone + 'static>(y: f32, row_h: f32, gw: f32, bg: Color) -> View<Msg> {
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
top: length(y),
|
||||
left: length(gw),
|
||||
right: length(0.0_f32),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: auto(),
|
||||
height: length(row_h),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(bg)
|
||||
}
|
||||
|
||||
/// Número global 1-based del renglón, alineado a la derecha del gutter.
|
||||
fn gutter_number<Msg: Clone + 'static>(
|
||||
number: u64,
|
||||
y: f32,
|
||||
gw: f32,
|
||||
row_h: f32,
|
||||
metrics: TermMetrics,
|
||||
palette: &TermPalette,
|
||||
) -> View<Msg> {
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(0.0_f32),
|
||||
top: length(y),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: length(gw - 6.0),
|
||||
height: length(row_h),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(
|
||||
number.to_string(),
|
||||
metrics.font_size * 0.85,
|
||||
palette.fg_line_number,
|
||||
Alignment::End,
|
||||
)
|
||||
.mono()
|
||||
}
|
||||
|
||||
/// Texto de un renglón, multicolor por runs, a la derecha del gutter.
|
||||
fn text_row<Msg: Clone + 'static>(
|
||||
text: &str,
|
||||
y: f32,
|
||||
gw: f32,
|
||||
row_h: f32,
|
||||
fg: Color,
|
||||
runs: Vec<(usize, usize, Color)>,
|
||||
metrics: TermMetrics,
|
||||
) -> View<Msg> {
|
||||
text_row_with_offset(text, y, gw, row_h, fg, runs, metrics, 0.0)
|
||||
}
|
||||
|
||||
/// Como [`text_row`] pero permite un offset horizontal en px (scroll_x del
|
||||
/// caller — el gutter queda fijo, el texto se desplaza).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text_row_with_offset<Msg: Clone + 'static>(
|
||||
text: &str,
|
||||
y: f32,
|
||||
gw: f32,
|
||||
row_h: f32,
|
||||
fg: Color,
|
||||
runs: Vec<(usize, usize, Color)>,
|
||||
metrics: TermMetrics,
|
||||
scroll_x: f32,
|
||||
) -> View<Msg> {
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
left: length(gw + 4.0 - scroll_x),
|
||||
top: length(y),
|
||||
right: auto(),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: length(4000.0_f32),
|
||||
height: length(row_h),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.text_runs(text.to_string(), metrics.font_size, fg, runs, Alignment::Start)
|
||||
.mono()
|
||||
}
|
||||
|
||||
/// Barra de scroll vertical del widget: track + thumb arrastrable. Reusa la
|
||||
/// geometría de `llimphi-widget-scroll` (`thumb_geometry`) dimensionada con el
|
||||
/// alto TOTAL virtual — el thumb refleja la posición en el scrollback completo.
|
||||
fn scrollbar<Msg, F>(
|
||||
scroll_y: f32,
|
||||
content_h: f32,
|
||||
viewport_h: f32,
|
||||
palette: &TermPalette,
|
||||
on_scroll: &Arc<F>,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + 'static,
|
||||
F: Fn(f32) -> Msg + Send + Sync + 'static,
|
||||
{
|
||||
let (thumb_h, thumb_y, offset_per_px) = thumb_geometry(scroll_y, content_h, viewport_h);
|
||||
let thumb_h = thumb_h.max(MIN_THUMB.min(viewport_h));
|
||||
|
||||
let on_thumb = Arc::clone(on_scroll);
|
||||
let thumb = View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
top: length(thumb_y),
|
||||
right: length(0.0_f32),
|
||||
left: auto(),
|
||||
bottom: auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: length(BAR_WIDTH),
|
||||
height: length(thumb_h),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bar_thumb)
|
||||
.hover_fill(palette.bar_thumb_hover)
|
||||
.radius((BAR_WIDTH * 0.5) as f64)
|
||||
.draggable(move |phase, _dx, dy| match phase {
|
||||
DragPhase::Move => Some((on_thumb)(dy * offset_per_px)),
|
||||
DragPhase::End => None,
|
||||
});
|
||||
|
||||
View::new(Style {
|
||||
position: Position::Absolute,
|
||||
inset: Rect {
|
||||
top: length(0.0_f32),
|
||||
right: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
left: auto(),
|
||||
},
|
||||
size: Size {
|
||||
width: length(BAR_WIDTH),
|
||||
height: auto(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bar_track)
|
||||
.children(vec![thumb])
|
||||
}
|
||||
|
||||
/// Ancho del gutter (px) para acomodar el número global más grande posible
|
||||
/// (`total_pushed`), con un padding fijo. Se fija por el total histórico (no por
|
||||
/// lo visible) para que el gutter no salte al scrollear, y es **el mismo** para
|
||||
/// todos los bloques (los números alinean entre cards).
|
||||
/// `y` (content coords) del renglón global `target_line` recorrido en el
|
||||
/// stream de `items`. Devuelve `None` si la línea no cae en ningún
|
||||
/// `Item::Lines`. **Puro** — base del auto-scroll al match de find: el
|
||||
/// caller compone `scroll_y = top - margin` y lo clampa al overflow.
|
||||
pub fn line_top_in_content(items_geo: &[ItemGeo], row_h: f32, target_line: usize) -> Option<f32> {
|
||||
let mut top = 0.0_f32;
|
||||
for it in items_geo {
|
||||
match it {
|
||||
ItemGeo::Chrome(h) => top += *h,
|
||||
ItemGeo::Lines(start, end) => {
|
||||
if target_line >= *start && target_line < *end {
|
||||
return Some(top + (target_line - start) as f32 * row_h);
|
||||
}
|
||||
top += (end.saturating_sub(*start)) as f32 * row_h;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Padding extra entre el borde derecho del gutter y el primer carácter del
|
||||
/// texto del renglón. Lo respeta `text_row` (línea ~495) y DEBE incluirse en
|
||||
/// los offsets de hit-test (`point_at`) y selección visual para que el rect
|
||||
/// pintado y el byte_col copiado coincidan con donde cayó el mouse.
|
||||
pub const TEXT_LEFT_PADDING_PX: f32 = 4.0;
|
||||
|
||||
pub fn gutter_width(store: &Scrollback, metrics: TermMetrics) -> f32 {
|
||||
let max_num = store.total_pushed().max(1);
|
||||
let digits = (max_num as f64).log10().floor() as usize + 1;
|
||||
metrics.char_width * digits as f32 + 10.0
|
||||
}
|
||||
|
||||
/// Clampa los runs de color al `[0, len]` del texto, descartando vacíos o fuera
|
||||
/// de rango — defensa contra runs stale del caller (el texto pudo cambiar).
|
||||
pub(crate) fn clamp_runs(
|
||||
runs: Vec<(usize, usize, Color)>,
|
||||
len: usize,
|
||||
) -> Vec<(usize, usize, Color)> {
|
||||
runs.into_iter()
|
||||
.filter_map(|(s, e, c)| {
|
||||
let s = s.min(len);
|
||||
let e = e.min(len);
|
||||
(s < e).then_some((s, e, c))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ROW: f32 = 18.0;
|
||||
|
||||
fn lines<Msg>(n: usize) -> Item<Msg> {
|
||||
Item::lines(0, n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_tops_accumulate() {
|
||||
let (tops, total) = item_tops(&[10.0, 20.0, 5.0]);
|
||||
assert_eq!(tops, vec![0.0, 10.0, 30.0]);
|
||||
assert_eq!(total, 35.0);
|
||||
let (t, tot) = item_tops(&[]);
|
||||
assert!(t.is_empty());
|
||||
assert_eq!(tot, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_items_picks_intersecting_blocks() {
|
||||
// 4 bloques de 100px = 400px total; viewport 150px.
|
||||
let (tops, total) = item_tops(&[100.0, 100.0, 100.0, 100.0]);
|
||||
// Scroll 0 → items 0,1 (y un toque del 2 si entrara, pero 150<200).
|
||||
let (a, b) = visible_items(&tops, total, 0.0, 150.0);
|
||||
assert_eq!((a, b), (0, 2));
|
||||
// Scroll 120 → ventana [120,270): items 1 y 2.
|
||||
let (a, b) = visible_items(&tops, total, 120.0, 150.0);
|
||||
assert_eq!((a, b), (1, 3));
|
||||
// Scroll al fondo → últimos items, sin pasarse.
|
||||
let (a, b) = visible_items(&tops, total, 1e9, 150.0);
|
||||
assert!(b == 4 && a >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_items_empty() {
|
||||
assert_eq!(visible_items(&[], 0.0, 0.0, 100.0), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_in_item_is_constant_cost() {
|
||||
// Un body de 1 M filas que arranca en top=40; viewport 600, scroll tal
|
||||
// que estamos en el medio del body. Materializa ~viewport/row filas.
|
||||
let (k0, k1) = visible_rows_in_item(40.0, 1_000_000, 9000.0, 600.0, ROW);
|
||||
assert!(k1 - k0 < 40, "costo constante, no {}", k1 - k0);
|
||||
// Anclado arriba del item.
|
||||
let (k0, k1) = visible_rows_in_item(40.0, 1_000_000, 0.0, 600.0, ROW);
|
||||
assert_eq!(k0, 0);
|
||||
assert!(k1 <= 34);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_in_item_clamps_to_nrows() {
|
||||
// Item chico totalmente visible.
|
||||
let (k0, k1) = visible_rows_in_item(0.0, 5, 0.0, 600.0, ROW);
|
||||
assert_eq!((k0, k1), (0, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_height_and_bottom() {
|
||||
let items: Vec<Item<()>> = vec![
|
||||
Item::chrome(30.0, View::new(Style::default())),
|
||||
lines(10),
|
||||
Item::chrome(30.0, View::new(Style::default())),
|
||||
lines(100),
|
||||
];
|
||||
let h = blocks_height(&items, ROW);
|
||||
assert_eq!(h, 30.0 + 10.0 * ROW + 30.0 + 100.0 * ROW);
|
||||
// Cabe? No (h grande), así que scroll_to_bottom > 0.
|
||||
assert!(blocks_scroll_to_bottom(&items, 200.0, ROW) > 0.0);
|
||||
// Si el viewport es enorme, no hay scroll.
|
||||
assert_eq!(blocks_scroll_to_bottom(&items, h + 100.0, ROW), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_height_chrome_vs_lines() {
|
||||
let c: Item<()> = Item::chrome(42.0, View::new(Style::default()));
|
||||
assert_eq!(c.height(ROW), 42.0);
|
||||
let l: Item<()> = Item::lines(5, 25);
|
||||
assert_eq!(l.height(ROW), 20.0 * ROW);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_runs_drops_out_of_range() {
|
||||
let c = Color::from_rgb8(1, 2, 3);
|
||||
let runs = vec![(0, 5, c), (3, 100, c), (50, 60, c), (4, 4, c)];
|
||||
let out = clamp_runs(runs, 10);
|
||||
assert_eq!(out, vec![(0, 5, c), (3, 10, c)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_top_camina_chrome_y_lineas() {
|
||||
// [Chrome(22), Lines(0..3), Chrome(10), Lines(0..2)]: target=0 → top=22;
|
||||
// target=2 → top=22+2*16=54; target=3 (en el segundo bloque, offset
|
||||
// chrome 10 + 3 filas anteriores * 16) → no aplica porque target=3 está
|
||||
// FUERA del primer Lines y el segundo es de 0..2 (otro rango). Test
|
||||
// ajustado: target=0 cae en el PRIMER Lines (que es 0..3).
|
||||
let items: Vec<ItemGeo> = vec![
|
||||
ItemGeo::Chrome(22.0),
|
||||
ItemGeo::Lines(0, 3),
|
||||
ItemGeo::Chrome(10.0),
|
||||
ItemGeo::Lines(10, 12),
|
||||
];
|
||||
// target_line=0 → primer Lines, k=0 → top = 22 + 0*16 = 22.
|
||||
assert_eq!(line_top_in_content(&items, 16.0, 0), Some(22.0));
|
||||
// target_line=2 → primer Lines, k=2 → top = 22 + 2*16 = 54.
|
||||
assert_eq!(line_top_in_content(&items, 16.0, 2), Some(54.0));
|
||||
// target_line=10 → segundo Lines, k=0 → top = 22 + 48 + 10 + 0 = 80.
|
||||
assert_eq!(line_top_in_content(&items, 16.0, 10), Some(80.0));
|
||||
// target_line=11 → segundo Lines, k=1 → top = 80 + 16 = 96.
|
||||
assert_eq!(line_top_in_content(&items, 16.0, 11), Some(96.0));
|
||||
// target fuera del store → None.
|
||||
assert_eq!(line_top_in_content(&items, 16.0, 99), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gutter_grows_with_line_count() {
|
||||
let m = TermMetrics::for_font_size(13.0);
|
||||
let mut s = Scrollback::new(0);
|
||||
s.push_line("a");
|
||||
let narrow = gutter_width(&s, m);
|
||||
for _ in 0..100_000 {
|
||||
s.push_line("x");
|
||||
}
|
||||
assert!(gutter_width(&s, m) > narrow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Pipeline GPU para la grilla de celdas del modo TUI (Fase 4 del SDD-TERMINAL).
|
||||
//!
|
||||
//! Define las **estructuras POD** (instance + uniforms), el **shader WGSL**
|
||||
//! y el **pipeline wgpu** (`CellPipeline`) que las consume. El wireado al
|
||||
//! `generic_grid_panel` vía `gpu_paint_with` va en el commit siguiente —
|
||||
//! acá ya se valida que el shader compila en un device headless.
|
||||
//!
|
||||
//! ## Layouts
|
||||
//!
|
||||
//! - **Instance** (32 B): `[cell_x: f32, cell_y: f32, uv_x: f32, uv_y: f32,
|
||||
//! uv_w: f32, uv_h: f32, fg_rgba: u32, bg_rgba: u32]`.
|
||||
//! Una por celda visible; el vertex stage emite los 4 corners (TriangleStrip).
|
||||
//! - **Uniforms** (32 B): `[viewport_w: f32, viewport_h: f32, cell_w: f32,
|
||||
//! cell_h: f32, atlas_w: f32, atlas_h: f32, _pad: [f32; 2]]`.
|
||||
//!
|
||||
//! El fragment samplea el atlas grayscale en `uv`; alpha = cobertura;
|
||||
//! out = mix(bg, fg, alpha). Blending estándar `OVER` por encima.
|
||||
//!
|
||||
//! ## Por qué quads instanciados
|
||||
//!
|
||||
//! Una grilla de 100×40 = 4000 celdas; en vello eso son ~4000 Views + 4000
|
||||
//! draws + el shaping de cada char. Con quads instanciados es UNA draw call
|
||||
//! de 4000 instancias y la GPU pinta todo en paralelo. Igual de simple
|
||||
//! para 200×80 (16k celdas) — patrón ya validado en `GpuPipelines.rects`.
|
||||
|
||||
/// Una celda lista para dibujar. **POD, repr(C)** — `as_bytes` la serializa
|
||||
/// a una secuencia plana de `f32`/`u32` little-endian para el buffer GPU.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[repr(C)]
|
||||
pub struct CellInstance {
|
||||
/// Posición (px) de la esquina superior-izquierda de la celda en
|
||||
/// viewport coords.
|
||||
pub cell_x: f32,
|
||||
pub cell_y: f32,
|
||||
/// Coords UV (px) del glifo en la textura del atlas. El shader las
|
||||
/// divide por `atlas_size` para obtener UVs normalizadas 0..1.
|
||||
pub uv_x: f32,
|
||||
pub uv_y: f32,
|
||||
pub uv_w: f32,
|
||||
pub uv_h: f32,
|
||||
/// Color foreground del glifo, RGBA8 empacado little-endian
|
||||
/// (`r | g<<8 | b<<16 | a<<24`).
|
||||
pub fg_rgba: u32,
|
||||
/// Color background de la celda, RGBA8 empacado.
|
||||
pub bg_rgba: u32,
|
||||
}
|
||||
|
||||
impl CellInstance {
|
||||
/// Tamaño en bytes del layout — debe coincidir con `array_stride` del
|
||||
/// pipeline en wgpu. Compile-time const para que el caller arme el
|
||||
/// vertex layout sin recalcular.
|
||||
pub const SIZE: usize = 32;
|
||||
|
||||
/// Serializa a 32 bytes little-endian para `Queue::write_buffer`.
|
||||
pub fn as_bytes(&self) -> [u8; Self::SIZE] {
|
||||
let mut out = [0u8; Self::SIZE];
|
||||
out[0..4].copy_from_slice(&self.cell_x.to_le_bytes());
|
||||
out[4..8].copy_from_slice(&self.cell_y.to_le_bytes());
|
||||
out[8..12].copy_from_slice(&self.uv_x.to_le_bytes());
|
||||
out[12..16].copy_from_slice(&self.uv_y.to_le_bytes());
|
||||
out[16..20].copy_from_slice(&self.uv_w.to_le_bytes());
|
||||
out[20..24].copy_from_slice(&self.uv_h.to_le_bytes());
|
||||
out[24..28].copy_from_slice(&self.fg_rgba.to_le_bytes());
|
||||
out[28..32].copy_from_slice(&self.bg_rgba.to_le_bytes());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Empaca un color `(r, g, b, a)` en un `u32` RGBA little-endian que el
|
||||
/// shader lee como `vec4<u32>` y normaliza a `vec4<f32>(r,g,b,a)/255`.
|
||||
pub fn pack_rgba(r: u8, g: u8, b: u8, a: u8) -> u32 {
|
||||
(r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | ((a as u32) << 24)
|
||||
}
|
||||
|
||||
/// Serializa un slice de instancias a un buffer de bytes contiguo. Útil
|
||||
/// para `Queue::write_buffer`.
|
||||
pub fn instances_to_bytes(cells: &[CellInstance]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(cells.len() * CellInstance::SIZE);
|
||||
for c in cells {
|
||||
out.extend_from_slice(&c.as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Uniforms del pipeline (un único buffer por draw). **POD, repr(C)**.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[repr(C)]
|
||||
pub struct CellUniforms {
|
||||
pub viewport_w: f32,
|
||||
pub viewport_h: f32,
|
||||
pub cell_w: f32,
|
||||
pub cell_h: f32,
|
||||
pub atlas_w: f32,
|
||||
pub atlas_h: f32,
|
||||
pub _pad0: f32,
|
||||
pub _pad1: f32,
|
||||
}
|
||||
|
||||
impl CellUniforms {
|
||||
/// 32 B — el bind group binding debe tener `min_binding_size = Some(32)`.
|
||||
pub const SIZE: usize = 32;
|
||||
|
||||
pub fn as_bytes(&self) -> [u8; Self::SIZE] {
|
||||
let mut out = [0u8; Self::SIZE];
|
||||
let fields = [
|
||||
self.viewport_w,
|
||||
self.viewport_h,
|
||||
self.cell_w,
|
||||
self.cell_h,
|
||||
self.atlas_w,
|
||||
self.atlas_h,
|
||||
self._pad0,
|
||||
self._pad1,
|
||||
];
|
||||
for (i, v) in fields.iter().enumerate() {
|
||||
out[i * 4..(i + 1) * 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// El shader WGSL del pipeline. Vertex stage usa `vertex_index` (0..4) para
|
||||
/// emitir los corners del quad como TriangleStrip. Fragment samplea el atlas
|
||||
/// grayscale y combina fg/bg por cobertura.
|
||||
pub const CELL_WGSL: &str = r#"
|
||||
struct Uniforms {
|
||||
viewport_size: vec2<f32>,
|
||||
cell_size: vec2<f32>,
|
||||
atlas_size: vec2<f32>,
|
||||
_pad: vec2<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
@group(0) @binding(1) var atlas_tex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_samp: sampler;
|
||||
|
||||
struct VsIn {
|
||||
@builtin(vertex_index) vi: u32,
|
||||
@location(0) cell_xy: vec2<f32>,
|
||||
@location(1) uv_rect: vec4<f32>,
|
||||
@location(2) fg_rgba: u32,
|
||||
@location(3) bg_rgba: u32,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) fg: vec4<f32>,
|
||||
@location(2) bg: vec4<f32>,
|
||||
};
|
||||
|
||||
fn unpack_rgba(c: u32) -> vec4<f32> {
|
||||
let r = f32(c & 0xFFu) / 255.0;
|
||||
let g = f32((c >> 8u) & 0xFFu) / 255.0;
|
||||
let b = f32((c >> 16u) & 0xFFu) / 255.0;
|
||||
let a = f32((c >> 24u) & 0xFFu) / 255.0;
|
||||
return vec4<f32>(r, g, b, a);
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_cell(in: VsIn) -> VsOut {
|
||||
// 4 corners del quad, TriangleStrip: (0,0) (1,0) (0,1) (1,1).
|
||||
let corner = vec2<f32>(f32(in.vi & 1u), f32((in.vi >> 1u) & 1u));
|
||||
let pixel_pos = in.cell_xy + corner * u.cell_size;
|
||||
// px → NDC: x in [-1,1], y in [1,-1] (y invertido para alinear con la
|
||||
// convención px-origin-top-left de viewport).
|
||||
let ndc = vec2<f32>(
|
||||
(pixel_pos.x / u.viewport_size.x) * 2.0 - 1.0,
|
||||
1.0 - (pixel_pos.y / u.viewport_size.y) * 2.0,
|
||||
);
|
||||
var out: VsOut;
|
||||
out.pos = vec4<f32>(ndc, 0.0, 1.0);
|
||||
// UV en pixels → UV normalizadas del atlas.
|
||||
let uv_px = in.uv_rect.xy + corner * in.uv_rect.zw;
|
||||
out.uv = uv_px / u.atlas_size;
|
||||
out.fg = unpack_rgba(in.fg_rgba);
|
||||
out.bg = unpack_rgba(in.bg_rgba);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_cell(in: VsOut) -> @location(0) vec4<f32> {
|
||||
// Atlas grayscale: la cobertura del glifo está en el canal R (la
|
||||
// textura R8Unorm devuelve (R, 0, 0, 1)).
|
||||
let cov = textureSample(atlas_tex, atlas_samp, in.uv).r;
|
||||
// Mezcla bg → fg por cobertura. Pre-multiplica alpha del fg para
|
||||
// que cubrir 100% rinda fg.a (no 1.0).
|
||||
let rgb = mix(in.bg.rgb, in.fg.rgb, cov * in.fg.a);
|
||||
let a = max(in.bg.a, cov * in.fg.a);
|
||||
return vec4<f32>(rgb, a);
|
||||
}
|
||||
"#;
|
||||
|
||||
use llimphi_hal::wgpu;
|
||||
|
||||
/// Pipeline wgpu del cell renderer — compila el shader y arma el bind
|
||||
/// group layout. Una sola instancia por proceso (o por `color_format`); el
|
||||
/// `draw` la consume con un atlas + instancias frescas por frame.
|
||||
///
|
||||
/// El atlas se sube aparte (su propio `wgpu::Texture` con format
|
||||
/// `R8Unorm`), y entra al bind group por la `binding=1`. Reusar el mismo
|
||||
/// atlas entre frames es OK — sólo se actualiza con `Queue::write_texture`
|
||||
/// cuando aparecen glifos nuevos (el `GlyphAtlas::take_dirty` lo señala).
|
||||
pub struct CellPipeline {
|
||||
pub pipeline: wgpu::RenderPipeline,
|
||||
pub bind_layout: wgpu::BindGroupLayout,
|
||||
pub sampler: wgpu::Sampler,
|
||||
}
|
||||
|
||||
impl CellPipeline {
|
||||
/// Compila el shader WGSL y construye el pipeline para escribir al
|
||||
/// `color_format` dado (típicamente `Rgba8Unorm`, el de la intermedia
|
||||
/// del `WinitSurface`).
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("llimphi-widget-terminal-cell-shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(CELL_WGSL.into()),
|
||||
});
|
||||
|
||||
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("llimphi-widget-terminal-cell-bgl"),
|
||||
entries: &[
|
||||
// 0: uniforms (32 B).
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// 1: atlas texture (R8Unorm).
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// 2: sampler.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("llimphi-widget-terminal-cell-pl"),
|
||||
bind_group_layouts: &[&bind_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
let color_targets = [Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})];
|
||||
|
||||
// Instance buffer: 32 B / instancia, 4 attributes (vec2 + vec4 + u32 + u32).
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("llimphi-widget-terminal-cell-pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_cell"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[wgpu::VertexBufferLayout {
|
||||
array_stride: CellInstance::SIZE as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &[
|
||||
// cell_xy
|
||||
wgpu::VertexAttribute {
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
},
|
||||
// uv_rect (vec4: uv_x, uv_y, uv_w, uv_h)
|
||||
wgpu::VertexAttribute {
|
||||
format: wgpu::VertexFormat::Float32x4,
|
||||
offset: 8,
|
||||
shader_location: 1,
|
||||
},
|
||||
// fg_rgba
|
||||
wgpu::VertexAttribute {
|
||||
format: wgpu::VertexFormat::Uint32,
|
||||
offset: 24,
|
||||
shader_location: 2,
|
||||
},
|
||||
// bg_rgba
|
||||
wgpu::VertexAttribute {
|
||||
format: wgpu::VertexFormat::Uint32,
|
||||
offset: 28,
|
||||
shader_location: 3,
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_cell"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &color_targets,
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
strip_index_format: None,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: None,
|
||||
unclipped_depth: false,
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("llimphi-widget-terminal-cell-sampler"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Self {
|
||||
pipeline,
|
||||
bind_layout,
|
||||
sampler,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: crea una textura `R8Unorm` del tamaño del atlas, sube los
|
||||
/// bytes y devuelve `(textura, view)`. El caller la mantiene viva
|
||||
/// entre frames y la pasa a `draw`. Sólo re-crear si las dimensiones
|
||||
/// del atlas cambian (p. ej. tras `GlyphAtlas::grow`).
|
||||
pub fn create_atlas_texture(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
atlas_pixels: &[u8],
|
||||
atlas_size: (u32, u32),
|
||||
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
let (w, h) = atlas_size;
|
||||
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("llimphi-widget-terminal-atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: w,
|
||||
height: h,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &tex,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
atlas_pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(w),
|
||||
rows_per_image: Some(h),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: w,
|
||||
height: h,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
(tex, view)
|
||||
}
|
||||
|
||||
/// Dibuja las celdas en `target_view`. **No** limpia el target (load:
|
||||
/// Load) — el caller decide la pasada previa (vello + selección). El
|
||||
/// blending alpha mezcla los glifos sobre lo que ya hay (la
|
||||
/// "pre-pasada vello" del SDD).
|
||||
pub fn draw(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target_view: &wgpu::TextureView,
|
||||
atlas_view: &wgpu::TextureView,
|
||||
cells: &[CellInstance],
|
||||
uniforms: CellUniforms,
|
||||
) {
|
||||
if cells.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Uniforms.
|
||||
let u_bytes = uniforms.as_bytes();
|
||||
let u_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("llimphi-widget-terminal-cell-u"),
|
||||
size: CellUniforms::SIZE as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(&u_buf, 0, &u_bytes);
|
||||
|
||||
// Instance buffer.
|
||||
let inst_bytes = instances_to_bytes(cells);
|
||||
let inst_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("llimphi-widget-terminal-cell-inst"),
|
||||
size: inst_bytes.len() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(&inst_buf, 0, &inst_bytes);
|
||||
|
||||
// Bind group: uniforms + atlas + sampler.
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("llimphi-widget-terminal-cell-bg"),
|
||||
layout: &self.bind_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: u_buf.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(atlas_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("llimphi-widget-terminal-cell-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, inst_buf.slice(..));
|
||||
pass.draw(0..4, 0..cells.len() as u32);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cell_instance_size_es_32_bytes() {
|
||||
// El pipeline asume `array_stride = 32`; un cambio acá rompería el
|
||||
// vertex layout silenciosamente. Tener el chequeo en test fija el
|
||||
// contrato.
|
||||
assert_eq!(CellInstance::SIZE, 32);
|
||||
assert_eq!(std::mem::size_of::<CellInstance>(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_uniforms_size_es_32_bytes() {
|
||||
assert_eq!(CellUniforms::SIZE, 32);
|
||||
assert_eq!(std::mem::size_of::<CellUniforms>(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_bytes_de_instance_es_round_trip_de_f32_u32() {
|
||||
let c = CellInstance {
|
||||
cell_x: 12.5,
|
||||
cell_y: 24.0,
|
||||
uv_x: 100.0,
|
||||
uv_y: 200.0,
|
||||
uv_w: 8.0,
|
||||
uv_h: 16.0,
|
||||
fg_rgba: 0xFF1122EE,
|
||||
bg_rgba: 0xAABBCCDD,
|
||||
};
|
||||
let b = c.as_bytes();
|
||||
assert_eq!(b.len(), 32);
|
||||
// Re-leemos cada campo del array byte little-endian.
|
||||
assert_eq!(f32::from_le_bytes(b[0..4].try_into().unwrap()), 12.5);
|
||||
assert_eq!(f32::from_le_bytes(b[4..8].try_into().unwrap()), 24.0);
|
||||
assert_eq!(f32::from_le_bytes(b[8..12].try_into().unwrap()), 100.0);
|
||||
assert_eq!(f32::from_le_bytes(b[12..16].try_into().unwrap()), 200.0);
|
||||
assert_eq!(f32::from_le_bytes(b[16..20].try_into().unwrap()), 8.0);
|
||||
assert_eq!(f32::from_le_bytes(b[20..24].try_into().unwrap()), 16.0);
|
||||
assert_eq!(u32::from_le_bytes(b[24..28].try_into().unwrap()), 0xFF1122EE);
|
||||
assert_eq!(u32::from_le_bytes(b[28..32].try_into().unwrap()), 0xAABBCCDD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_rgba_es_little_endian() {
|
||||
assert_eq!(pack_rgba(0x11, 0x22, 0x33, 0xFF), 0xFF332211);
|
||||
assert_eq!(pack_rgba(0, 0, 0, 0), 0);
|
||||
assert_eq!(pack_rgba(255, 255, 255, 255), 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_to_bytes_concatena_correctamente() {
|
||||
let cs = vec![
|
||||
CellInstance {
|
||||
cell_x: 0.0, cell_y: 0.0, uv_x: 0.0, uv_y: 0.0,
|
||||
uv_w: 0.0, uv_h: 0.0, fg_rgba: 0x12345678, bg_rgba: 0,
|
||||
},
|
||||
CellInstance {
|
||||
cell_x: 1.0, cell_y: 2.0, uv_x: 3.0, uv_y: 4.0,
|
||||
uv_w: 5.0, uv_h: 6.0, fg_rgba: 0xCAFEBABE, bg_rgba: 0xDEADBEEF,
|
||||
},
|
||||
];
|
||||
let b = instances_to_bytes(&cs);
|
||||
assert_eq!(b.len(), 64);
|
||||
// Segunda instancia arranca en byte 32.
|
||||
assert_eq!(f32::from_le_bytes(b[32..36].try_into().unwrap()), 1.0);
|
||||
assert_eq!(u32::from_le_bytes(b[56..60].try_into().unwrap()), 0xCAFEBABE);
|
||||
assert_eq!(u32::from_le_bytes(b[60..64].try_into().unwrap()), 0xDEADBEEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniforms_as_bytes_pone_dims_en_orden() {
|
||||
let u = CellUniforms {
|
||||
viewport_w: 800.0,
|
||||
viewport_h: 600.0,
|
||||
cell_w: 8.0,
|
||||
cell_h: 16.0,
|
||||
atlas_w: 512.0,
|
||||
atlas_h: 256.0,
|
||||
_pad0: 0.0,
|
||||
_pad1: 0.0,
|
||||
};
|
||||
let b = u.as_bytes();
|
||||
assert_eq!(f32::from_le_bytes(b[0..4].try_into().unwrap()), 800.0);
|
||||
assert_eq!(f32::from_le_bytes(b[12..16].try_into().unwrap()), 16.0); // cell_h
|
||||
assert_eq!(f32::from_le_bytes(b[16..20].try_into().unwrap()), 512.0); // atlas_w
|
||||
assert_eq!(f32::from_le_bytes(b[20..24].try_into().unwrap()), 256.0); // atlas_h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgsl_shader_no_es_vacio_y_define_entry_points() {
|
||||
// Smoke check: la string del shader existe y declara las dos
|
||||
// entry points que el pipeline va a referenciar. La validación
|
||||
// sintáctica WGSL ocurre cuando `device.create_shader_module` la
|
||||
// compile en el commit de pipeline.
|
||||
assert!(CELL_WGSL.contains("@vertex"));
|
||||
assert!(CELL_WGSL.contains("@fragment"));
|
||||
assert!(CELL_WGSL.contains("vs_cell"));
|
||||
assert!(CELL_WGSL.contains("fs_cell"));
|
||||
assert!(CELL_WGSL.len() > 200, "shader sospechosamente corto");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Búsqueda sobre el `Scrollback` — base de Ctrl+F del SDD-TERMINAL §Fase 3.
|
||||
//!
|
||||
//! Diseño: barata pero correcta. Recorre todas las líneas del store y
|
||||
//! reporta los rangos `(line, start_byte, end_byte)` de cada ocurrencia.
|
||||
//! Sin streaming, sin índice — para los típicos cientos de miles de líneas
|
||||
//! del shell es suficiente (un `memmem` por línea, lineal en el contenido).
|
||||
//!
|
||||
//! Para infinitos masivos (millones de líneas), el `find` ya es O(N) en el
|
||||
//! contenido, no en el render — el scroll y la pintada siguen siendo O(1).
|
||||
//! Si en algún momento aprieta, se puede pre-indexar n-gramas; no hoy.
|
||||
//!
|
||||
//! Case-insensitive: lowercase ambos lados (sin Unicode-aware folding por
|
||||
//! ahora — ASCII alcanza para el caso shell típico).
|
||||
|
||||
use crate::store::Scrollback;
|
||||
|
||||
/// Una coincidencia de búsqueda en el scrollback. `start`/`end` son offsets
|
||||
/// **en bytes** UTF-8 del texto de la línea (slice-safe: el caller puede
|
||||
/// hacer `&text[start..end]` sin clampear).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FindMatch {
|
||||
pub line: usize,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
/// Opciones de búsqueda. Defaults: case-sensitive, query literal (sin regex).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct FindOpts {
|
||||
/// `true` = lowercase ambos lados antes de comparar (ASCII fold).
|
||||
pub case_insensitive: bool,
|
||||
}
|
||||
|
||||
/// Busca todas las ocurrencias **no superpuestas** de `query` en el `store`,
|
||||
/// línea por línea, en orden. Empty query → `Vec::new()` (paridad con la
|
||||
/// barra de find de la mayoría de editores: vacío = "no hay nada que
|
||||
/// resaltar"). El consumo es O(total_bytes) en el contenido del store.
|
||||
///
|
||||
/// Las coincidencias caen siempre en límites de char UTF-8 (vienen del
|
||||
/// scanner de bytes y se snap-ean al borde más cercano hacia abajo si la
|
||||
/// query atraviesa una codepoint, que con `find` literal no debería pasar).
|
||||
pub fn find_matches(store: &Scrollback, query: &str, opts: FindOpts) -> Vec<FindMatch> {
|
||||
if query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let needle = if opts.case_insensitive {
|
||||
query.to_ascii_lowercase()
|
||||
} else {
|
||||
query.to_string()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for line in 0..store.len() {
|
||||
let Some(text) = store.line(line) else { continue };
|
||||
let haystack_owned;
|
||||
let haystack: &str = if opts.case_insensitive {
|
||||
haystack_owned = text.to_ascii_lowercase();
|
||||
&haystack_owned
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let mut cursor = 0usize;
|
||||
while cursor < haystack.len() {
|
||||
let Some(rel) = haystack[cursor..].find(&needle) else {
|
||||
break;
|
||||
};
|
||||
let start = cursor + rel;
|
||||
let end = start + needle.len();
|
||||
out.push(FindMatch { line, start, end });
|
||||
// Avance no-superposición: una ocurrencia consume su rango, la
|
||||
// siguiente arranca DESPUÉS. Si la query es vacía no llegamos
|
||||
// acá (ya filtrado arriba), así que `end > start` siempre.
|
||||
cursor = end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Avanza al siguiente match desde `current` (envuelve al primero si está
|
||||
/// al final). Si `matches` está vacío devuelve `None`. `None` en `current`
|
||||
/// equivale a "no hay actual" → arranca por el primero.
|
||||
pub fn next_match(matches: &[FindMatch], current: Option<usize>) -> Option<usize> {
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(match current {
|
||||
None => 0,
|
||||
Some(i) => (i + 1) % matches.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Retrocede al match previo desde `current` (envuelve al último si está
|
||||
/// al principio). Mismas semánticas que [`next_match`].
|
||||
pub fn prev_match(matches: &[FindMatch], current: Option<usize>) -> Option<usize> {
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(match current {
|
||||
None => matches.len() - 1,
|
||||
Some(i) => (i + matches.len() - 1) % matches.len(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store_of(lines: &[&str]) -> Scrollback {
|
||||
let mut s = Scrollback::new(0);
|
||||
for l in lines {
|
||||
s.push_line(l);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_vacia_no_devuelve_nada() {
|
||||
let s = store_of(&["foo", "bar"]);
|
||||
assert_eq!(find_matches(&s, "", FindOpts::default()), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn una_ocurrencia_por_linea() {
|
||||
let s = store_of(&["foo bar baz", "qux foo quux"]);
|
||||
let m = find_matches(&s, "foo", FindOpts::default());
|
||||
assert_eq!(
|
||||
m,
|
||||
vec![
|
||||
FindMatch { line: 0, start: 0, end: 3 },
|
||||
FindMatch { line: 1, start: 4, end: 7 },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varias_ocurrencias_en_la_misma_linea_no_se_superponen() {
|
||||
// "aaa" → en "aaaaa" hay 1 match en 0..3 y otro en 3..6 (no en 1..4).
|
||||
let s = store_of(&["aaaaaa"]);
|
||||
let m = find_matches(&s, "aaa", FindOpts::default());
|
||||
assert_eq!(
|
||||
m,
|
||||
vec![
|
||||
FindMatch { line: 0, start: 0, end: 3 },
|
||||
FindMatch { line: 0, start: 3, end: 6 },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_sensitive_por_defecto() {
|
||||
let s = store_of(&["Foo", "FOO", "foo"]);
|
||||
let m = find_matches(&s, "foo", FindOpts::default());
|
||||
assert_eq!(m, vec![FindMatch { line: 2, start: 0, end: 3 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_matchea_todas_las_variantes() {
|
||||
let s = store_of(&["Foo", "FOO", "foo"]);
|
||||
let m = find_matches(&s, "foo", FindOpts { case_insensitive: true });
|
||||
assert_eq!(m.len(), 3);
|
||||
assert_eq!(m[0].line, 0);
|
||||
assert_eq!(m[1].line, 1);
|
||||
assert_eq!(m[2].line, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match_devuelve_vec_vacio() {
|
||||
let s = store_of(&["uno", "dos"]);
|
||||
assert!(find_matches(&s, "xyz", FindOpts::default()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_utf8_funciona_al_ser_busqueda_literal_byte_a_byte() {
|
||||
// "café" tiene 'é' = 2 bytes. Buscamos "afé" — match en bytes 1..5.
|
||||
let s = store_of(&["café"]);
|
||||
let m = find_matches(&s, "afé", FindOpts::default());
|
||||
assert_eq!(m, vec![FindMatch { line: 0, start: 1, end: 5 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_match_envuelve_al_primero() {
|
||||
let m = vec![
|
||||
FindMatch { line: 0, start: 0, end: 1 },
|
||||
FindMatch { line: 1, start: 0, end: 1 },
|
||||
];
|
||||
assert_eq!(next_match(&m, None), Some(0));
|
||||
assert_eq!(next_match(&m, Some(0)), Some(1));
|
||||
assert_eq!(next_match(&m, Some(1)), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prev_match_envuelve_al_ultimo() {
|
||||
let m = vec![
|
||||
FindMatch { line: 0, start: 0, end: 1 },
|
||||
FindMatch { line: 1, start: 0, end: 1 },
|
||||
];
|
||||
assert_eq!(prev_match(&m, None), Some(1));
|
||||
assert_eq!(prev_match(&m, Some(0)), Some(1));
|
||||
assert_eq!(prev_match(&m, Some(1)), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_y_prev_en_lista_vacia_son_none() {
|
||||
let m: Vec<FindMatch> = Vec::new();
|
||||
assert_eq!(next_match(&m, None), None);
|
||||
assert_eq!(next_match(&m, Some(0)), None);
|
||||
assert_eq!(prev_match(&m, None), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Atlas de glifos para el render GPU-directo de la grilla del modo TUI
|
||||
//! (Fase 4 del SDD-TERMINAL). Pura CPU: rasteriza cada char a una celda
|
||||
//! del atlas con `fontdue` y devuelve coords UV para que el shader de
|
||||
//! quads instanciados las samplee.
|
||||
//!
|
||||
//! ## Diseño
|
||||
//!
|
||||
//! - **Grilla fija de celdas**: el atlas es una imagen `atlas_w × atlas_h`
|
||||
//! en escala de grises (1 byte por pixel: cobertura del glifo). Cada
|
||||
//! celda mide `cell_w × cell_h` px y aloja UN glifo. `cols × rows`
|
||||
//! celdas totales (computable desde el tamaño y el font size).
|
||||
//! - **Mapa `char → slot`**: cargado on-demand (primera vez que se pide
|
||||
//! un char se rasteriza y se asigna la próxima celda libre). Sin LRU
|
||||
//! por ahora — atlas grande de entrada (suficiente para ASCII +
|
||||
//! símbolos comunes); si se llena, crece duplicando alto.
|
||||
//! - **Bytes RAW**: el caller decide cuándo subir a GPU (toda la imagen
|
||||
//! o sólo el rect del slot recién agregado, vía `dirty_rect`). Esto
|
||||
//! mantiene el atlas **agnóstico de wgpu** (testeable headless).
|
||||
//!
|
||||
//! ## Métricas
|
||||
//!
|
||||
//! Las celdas son del **tamaño máximo** del glifo (incluye padding para
|
||||
//! que el render del shader pueda ofset-ear el origen del baseline sin
|
||||
//! cortar). El caller (el pipeline) usa `metrics_for` para alinear cada
|
||||
//! quad al baseline correcto dentro de la fila.
|
||||
|
||||
use fontdue::{Font, FontSettings};
|
||||
|
||||
/// Slot de un glifo en el atlas. Coords en píxeles del atlas (no UV
|
||||
/// normalizadas — el caller las divide por `(atlas_w, atlas_h)` al subirlas
|
||||
/// al shader). El offset `(xmin, ymin)` es del bitmap respecto del origen
|
||||
/// del cell — el shader lo aplica al posicionar el quad de salida.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct GlyphSlot {
|
||||
/// x del píxel superior-izquierdo del glifo (dentro de su celda).
|
||||
pub px: u32,
|
||||
/// y idem.
|
||||
pub py: u32,
|
||||
/// Ancho del bitmap del glifo (≤ cell_w).
|
||||
pub w: u32,
|
||||
/// Alto del bitmap (≤ cell_h).
|
||||
pub h: u32,
|
||||
/// Offset horizontal del glifo respecto del origen del cell (typically 0).
|
||||
pub xmin: i32,
|
||||
/// Offset vertical — `metrics.ymin` de fontdue (positivo = baseline arriba).
|
||||
pub ymin: i32,
|
||||
/// Advance horizontal (para mono, igual a `metrics.advance_width` o ~cell_w).
|
||||
pub advance: f32,
|
||||
}
|
||||
|
||||
/// Rect en píxeles del atlas: `(x, y, w, h)`. Empacado por
|
||||
/// `add_dirty_rect` para que el caller sepa qué subir a GPU.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DirtyRect {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
/// Atlas de glifos rasterizados sobre una textura grayscale.
|
||||
pub struct GlyphAtlas {
|
||||
font: Font,
|
||||
/// Tamaño del font en píxeles (input al rasterizer).
|
||||
font_size: f32,
|
||||
/// Tamaño de cada celda en píxeles (ancho/alto del cell, no del glifo).
|
||||
cell_w: u32,
|
||||
cell_h: u32,
|
||||
/// Columnas/filas vigentes del atlas.
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
/// Bytes del atlas grayscale (`atlas_w * atlas_h` bytes, row-major).
|
||||
pixels: Vec<u8>,
|
||||
/// Mapeo `char → slot_index_lineal` (filled on demand).
|
||||
map: std::collections::HashMap<char, u32>,
|
||||
/// Próximo slot libre (lineal `0..cols*rows`). `None` cuando está lleno.
|
||||
next_slot: Option<u32>,
|
||||
/// Rect vigente que cambió desde la última `take_dirty`. `None` = nada
|
||||
/// que subir. Acumula con union; el caller llama `take_dirty()` después
|
||||
/// de subir y resetea.
|
||||
dirty: Option<DirtyRect>,
|
||||
}
|
||||
|
||||
impl GlyphAtlas {
|
||||
/// Construye el atlas con `font_bytes` (TTF/OTF), `font_size_px` y un
|
||||
/// número inicial de `cols`/`rows`. El alto de cada cell sale del font
|
||||
/// (`line_metrics`), el ancho del max(advance, 'M'). Si el font no
|
||||
/// parsea, devuelve `None` — el caller decide el fallback.
|
||||
pub fn new(font_bytes: &[u8], font_size_px: f32, cols: u32, rows: u32) -> Option<Self> {
|
||||
let font = Font::from_bytes(font_bytes, FontSettings::default()).ok()?;
|
||||
// Cell metrics: alto del line (ascent - descent + line_gap),
|
||||
// ancho del advance del 'M' (proxy para mono). Padding 1 px por
|
||||
// lado para que glifos con bearing negativo no sangren.
|
||||
let line = font.horizontal_line_metrics(font_size_px)?;
|
||||
let cell_h = (line.new_line_size.ceil() as u32).max(1) + 2;
|
||||
// Para mono asumimos que 'M' marca el ancho de cell. Si el font no
|
||||
// tiene 'M', cae a advance del primer glifo no-cero o a 8 px.
|
||||
let m_metrics = font.metrics('M', font_size_px);
|
||||
let cell_w = (m_metrics.advance_width.ceil() as u32).max(1) + 2;
|
||||
let atlas_w = cell_w * cols;
|
||||
let atlas_h = cell_h * rows;
|
||||
Some(Self {
|
||||
font,
|
||||
font_size: font_size_px,
|
||||
cell_w,
|
||||
cell_h,
|
||||
cols,
|
||||
rows,
|
||||
pixels: vec![0u8; (atlas_w * atlas_h) as usize],
|
||||
map: std::collections::HashMap::new(),
|
||||
next_slot: Some(0),
|
||||
dirty: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tamaño total del atlas en píxeles.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.cell_w * self.cols, self.cell_h * self.rows)
|
||||
}
|
||||
|
||||
/// Tamaño de cada cell (ancho × alto, px).
|
||||
pub fn cell_size(&self) -> (u32, u32) {
|
||||
(self.cell_w, self.cell_h)
|
||||
}
|
||||
|
||||
/// Buffer crudo del atlas (grayscale 1 byte por pixel, row-major,
|
||||
/// stride = `atlas_w` bytes). Inmutable — el caller sube esto a la
|
||||
/// textura GPU directamente.
|
||||
pub fn pixels(&self) -> &[u8] {
|
||||
&self.pixels
|
||||
}
|
||||
|
||||
/// Si hay un rect modificado desde la última llamada, lo devuelve y
|
||||
/// resetea. Patrón "consume on read": el caller que llama esto es el
|
||||
/// que está por hacer el upload a GPU.
|
||||
pub fn take_dirty(&mut self) -> Option<DirtyRect> {
|
||||
self.dirty.take()
|
||||
}
|
||||
|
||||
/// Devuelve el slot del glifo `ch`. Si no estaba cacheado, lo rasteriza
|
||||
/// y le asigna la próxima celda libre (marcando el rect como dirty).
|
||||
/// Si el atlas está lleno devuelve `None` (el caller puede llamar
|
||||
/// `grow()` y reintentar).
|
||||
pub fn glyph_for(&mut self, ch: char) -> Option<GlyphSlot> {
|
||||
if let Some(&slot) = self.map.get(&ch) {
|
||||
return Some(self.slot_at(slot, ch));
|
||||
}
|
||||
let slot = self.next_slot?;
|
||||
self.rasterize_to(ch, slot);
|
||||
self.map.insert(ch, slot);
|
||||
let next = slot + 1;
|
||||
self.next_slot = if next < self.cols * self.rows { Some(next) } else { None };
|
||||
Some(self.slot_at(slot, ch))
|
||||
}
|
||||
|
||||
/// Duplica el alto del atlas (`rows *= 2`) para hacer más espacio. El
|
||||
/// buffer se extiende con ceros; los glifos viejos quedan donde
|
||||
/// estaban; `next_slot` apunta a la primera celda nueva. El rect
|
||||
/// dirty se setea sobre la mitad nueva. Es la estrategia más simple
|
||||
/// que mantiene los slots viejos válidos sin re-empacar.
|
||||
pub fn grow(&mut self) {
|
||||
let old_rows = self.rows;
|
||||
let new_rows = old_rows.saturating_mul(2).max(old_rows + 1);
|
||||
let (atlas_w, _) = self.size();
|
||||
let old_pixels = std::mem::take(&mut self.pixels);
|
||||
self.rows = new_rows;
|
||||
let new_atlas_h = self.cell_h * new_rows;
|
||||
self.pixels = vec![0u8; (atlas_w * new_atlas_h) as usize];
|
||||
// Copy old block at the top.
|
||||
self.pixels[..old_pixels.len()].copy_from_slice(&old_pixels);
|
||||
// El próximo slot arranca en la primera celda nueva.
|
||||
self.next_slot = Some(self.cols * old_rows);
|
||||
// Toda la mitad nueva está sucia (zeros, pero el caller necesita
|
||||
// saber que el atlas creció para re-subir si quiere texturas
|
||||
// ajustadas; en práctica se re-aloca la textura GPU al detectar
|
||||
// size change).
|
||||
self.add_dirty(DirtyRect {
|
||||
x: 0,
|
||||
y: self.cell_h * old_rows,
|
||||
w: atlas_w,
|
||||
h: self.cell_h * (new_rows - old_rows),
|
||||
});
|
||||
}
|
||||
|
||||
/// Cantidad de glifos cacheados hasta ahora (informativo).
|
||||
pub fn cached_count(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
/// Capacidad total del atlas en celdas (`cols * rows`).
|
||||
pub fn capacity(&self) -> u32 {
|
||||
self.cols * self.rows
|
||||
}
|
||||
|
||||
// ── helpers privados ──────────────────────────────────────────────
|
||||
|
||||
fn slot_at(&self, slot: u32, ch: char) -> GlyphSlot {
|
||||
let col = slot % self.cols;
|
||||
let row = slot / self.cols;
|
||||
let (m, _) = self.font.rasterize(ch, self.font_size);
|
||||
GlyphSlot {
|
||||
px: col * self.cell_w,
|
||||
py: row * self.cell_h,
|
||||
w: m.width as u32,
|
||||
h: m.height as u32,
|
||||
xmin: m.xmin,
|
||||
ymin: m.ymin,
|
||||
advance: m.advance_width,
|
||||
}
|
||||
}
|
||||
|
||||
fn rasterize_to(&mut self, ch: char, slot: u32) {
|
||||
let (m, bitmap) = self.font.rasterize(ch, self.font_size);
|
||||
let col = slot % self.cols;
|
||||
let row = slot / self.cols;
|
||||
let px = col * self.cell_w;
|
||||
let py = row * self.cell_h;
|
||||
let (atlas_w, _) = self.size();
|
||||
// Blit del bitmap a (px, py). El glifo puede ser más chico que la
|
||||
// celda — el resto queda en 0 (transparente para el shader).
|
||||
let bw = m.width as u32;
|
||||
let bh = m.height as u32;
|
||||
for y in 0..bh.min(self.cell_h) {
|
||||
for x in 0..bw.min(self.cell_w) {
|
||||
let src = (y * bw + x) as usize;
|
||||
let dst = ((py + y) * atlas_w + (px + x)) as usize;
|
||||
if src < bitmap.len() && dst < self.pixels.len() {
|
||||
self.pixels[dst] = bitmap[src];
|
||||
}
|
||||
}
|
||||
}
|
||||
self.add_dirty(DirtyRect {
|
||||
x: px,
|
||||
y: py,
|
||||
w: self.cell_w,
|
||||
h: self.cell_h,
|
||||
});
|
||||
}
|
||||
|
||||
fn add_dirty(&mut self, r: DirtyRect) {
|
||||
self.dirty = Some(match self.dirty {
|
||||
None => r,
|
||||
Some(prev) => union_rects(prev, r),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn union_rects(a: DirtyRect, b: DirtyRect) -> DirtyRect {
|
||||
let x = a.x.min(b.x);
|
||||
let y = a.y.min(b.y);
|
||||
let r = (a.x + a.w).max(b.x + b.w);
|
||||
let bo = (a.y + a.h).max(b.y + b.h);
|
||||
DirtyRect {
|
||||
x,
|
||||
y,
|
||||
w: r - x,
|
||||
h: bo - y,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MONO: &[u8] = llimphi_ui::llimphi_text::MONO_FONT_BYTES;
|
||||
|
||||
fn atlas() -> GlyphAtlas {
|
||||
GlyphAtlas::new(MONO, 14.0, 16, 4).expect("font parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_compone_dimensiones_segun_font_y_cols_rows() {
|
||||
let a = atlas();
|
||||
let (cw, ch) = a.cell_size();
|
||||
let (w, h) = a.size();
|
||||
assert!(cw > 0 && ch > 0);
|
||||
assert_eq!(w, cw * 16);
|
||||
assert_eq!(h, ch * 4);
|
||||
assert_eq!(a.capacity(), 64);
|
||||
assert_eq!(a.cached_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primer_glyph_for_rasteriza_y_marca_dirty() {
|
||||
let mut a = atlas();
|
||||
let s = a.glyph_for('A').expect("slot");
|
||||
assert_eq!(s.px, 0);
|
||||
assert_eq!(s.py, 0);
|
||||
assert!(s.w > 0 && s.h > 0);
|
||||
assert_eq!(a.cached_count(), 1);
|
||||
let dirty = a.take_dirty().expect("dirty");
|
||||
assert_eq!(dirty.x, 0);
|
||||
assert_eq!(dirty.y, 0);
|
||||
// Tras consumir, sin dirty pendiente.
|
||||
assert!(a.take_dirty().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segundo_glyph_va_a_la_proxima_celda() {
|
||||
let mut a = atlas();
|
||||
let _ = a.glyph_for('A').unwrap();
|
||||
let _ = a.take_dirty();
|
||||
let s = a.glyph_for('B').unwrap();
|
||||
let (cw, _) = a.cell_size();
|
||||
assert_eq!(s.px, cw);
|
||||
assert_eq!(s.py, 0);
|
||||
assert_eq!(a.cached_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_repetido_no_aumenta_la_cache() {
|
||||
let mut a = atlas();
|
||||
let s1 = a.glyph_for('A').unwrap();
|
||||
let _ = a.take_dirty();
|
||||
let s2 = a.glyph_for('A').unwrap();
|
||||
assert_eq!(s1, s2);
|
||||
assert_eq!(a.cached_count(), 1);
|
||||
// Lookup cacheado no marca dirty.
|
||||
assert!(a.take_dirty().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fila_se_envuelve_a_la_siguiente_al_completar_columnas() {
|
||||
let mut a = atlas();
|
||||
let (cw, ch) = a.cell_size();
|
||||
for c in 'a'..='z' {
|
||||
let _ = a.glyph_for(c);
|
||||
}
|
||||
// Tras 16 columnas se va a la segunda fila.
|
||||
let s_q = a.glyph_for('a').unwrap(); // ya está; mismo slot.
|
||||
assert_eq!((s_q.px, s_q.py), (0, 0));
|
||||
// El char 17 (índice 16 en 0-based) cayó en (col=0, row=1).
|
||||
let s17 = a.glyph_for(('a' as u32 + 16) as u8 as char).unwrap();
|
||||
assert_eq!((s17.px, s17.py), (0, ch));
|
||||
// El char 18 cae en (col=1, row=1).
|
||||
let s18 = a.glyph_for(('a' as u32 + 17) as u8 as char).unwrap();
|
||||
assert_eq!((s18.px, s18.py), (cw, ch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_for_devuelve_none_cuando_lleno() {
|
||||
let mut a = GlyphAtlas::new(MONO, 14.0, 2, 2).unwrap(); // capacidad 4
|
||||
for c in ['A', 'B', 'C', 'D'] {
|
||||
assert!(a.glyph_for(c).is_some(), "{c}");
|
||||
}
|
||||
// El quinto no entra.
|
||||
assert!(a.glyph_for('E').is_none());
|
||||
assert_eq!(a.cached_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grow_duplica_rows_y_libera_celdas() {
|
||||
let mut a = GlyphAtlas::new(MONO, 14.0, 2, 2).unwrap(); // capacidad 4
|
||||
for c in ['A', 'B', 'C', 'D'] {
|
||||
a.glyph_for(c).unwrap();
|
||||
}
|
||||
assert!(a.glyph_for('E').is_none());
|
||||
a.grow();
|
||||
assert_eq!(a.capacity(), 8);
|
||||
// Glifos viejos siguen en su slot original.
|
||||
let s_a = a.glyph_for('A').unwrap();
|
||||
assert_eq!((s_a.px, s_a.py), (0, 0));
|
||||
// 'E' entra en la mitad nueva (slot 4 → col 0, row 2).
|
||||
let (_, ch) = a.cell_size();
|
||||
let s_e = a.glyph_for('E').unwrap();
|
||||
assert_eq!((s_e.px, s_e.py), (0, ch * 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_acumula_union_hasta_take() {
|
||||
let mut a = atlas();
|
||||
a.glyph_for('A').unwrap();
|
||||
a.glyph_for('B').unwrap();
|
||||
a.glyph_for('C').unwrap();
|
||||
let d = a.take_dirty().unwrap();
|
||||
let (cw, ch) = a.cell_size();
|
||||
// Tres celdas en fila: x=0..3*cw, y=0..ch.
|
||||
assert_eq!(d.x, 0);
|
||||
assert_eq!(d.y, 0);
|
||||
assert_eq!(d.w, cw * 3);
|
||||
assert_eq!(d.h, ch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixels_buffer_se_llena_con_algo_distinto_de_cero_tras_rasterizar() {
|
||||
let mut a = atlas();
|
||||
a.glyph_for('A').unwrap();
|
||||
// Algún pixel del primer cell debe ser no-cero (alpha del glifo).
|
||||
let (cw, ch) = a.cell_size();
|
||||
let (atlas_w, _) = a.size();
|
||||
let mut any = false;
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
if a.pixels()[((y * atlas_w) + x) as usize] != 0 {
|
||||
any = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(any, "el cell de 'A' debe tener pixels rasterizados");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! `llimphi-widget-terminal` — superficie de terminal **infinita y
|
||||
//! virtualizada**.
|
||||
//!
|
||||
//! Diseño completo: `02_ruway/shuma/SDD-TERMINAL.md`. El control reemplaza por
|
||||
//! fases al `output_pane` del shell: scrollback ilimitado a costo de render
|
||||
//! **constante** (sólo se pinta la ventana visible), tres modos sobre la misma
|
||||
//! tela (línea IDE / grilla TUI / híbrido) y GPU directo donde paga.
|
||||
//!
|
||||
//! **Fase 0:** la **Capa 0** — el [`store::Scrollback`], store de scrollback
|
||||
//! append-only con índice de líneas, cap por memoria y acceso O(1). Puro, sin
|
||||
//! dependencias de UI: el núcleo agnóstico vive aparte de quien lo pinta
|
||||
//! (Regla 2).
|
||||
//!
|
||||
//! **Fase 1:** modo línea — [`view::line_surface`] materializa sólo las filas
|
||||
//! visibles bajo un `scroll_y` propio del widget (costo de render **constante**
|
||||
//! a scrollback ilimitado), numeración global y color por runs.
|
||||
//!
|
||||
//! **Fase 2 (esto):** la **Capa 1** — el modelo de **bloques** ([`blocks`]):
|
||||
//! el stream es una secuencia de [`blocks::Item`]s (chrome de alto fijo que el
|
||||
//! caller pinta + rangos de líneas del store), virtualizados sobre alturas
|
||||
//! mixtas con búsqueda binaria; colapsar un bloque = no emitir su body. Mapea
|
||||
//! el `output_pane` del shell (header/badge/etapas/colapso) sin que el widget
|
||||
//! sepa de comandos (Regla 2). El modo línea de la Fase 1 es el caso de un solo
|
||||
//! `Item::Lines`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod blocks;
|
||||
pub mod cell_pipeline;
|
||||
pub mod find;
|
||||
pub mod glyph_atlas;
|
||||
pub mod select;
|
||||
pub mod store;
|
||||
pub mod view;
|
||||
|
||||
pub use blocks::{
|
||||
block_surface, block_surface_with_scroll, block_surface_with_selection, blocks_height,
|
||||
blocks_scroll_to_bottom, gutter_width, line_top_in_content, Item, ItemGeo, SelectionConfig,
|
||||
TEXT_LEFT_PADDING_PX,
|
||||
};
|
||||
pub use find::{find_matches, next_match, prev_match, FindMatch, FindOpts};
|
||||
pub use cell_pipeline::{
|
||||
instances_to_bytes, pack_rgba, CellInstance, CellPipeline, CellUniforms, CELL_WGSL,
|
||||
};
|
||||
pub use glyph_atlas::{DirtyRect, GlyphAtlas, GlyphSlot};
|
||||
pub use select::{point_at, point_at_geo, selection_rects, HighlightRect, Point, SelectionRange};
|
||||
pub use store::{Scrollback, SpillStore};
|
||||
pub use view::{
|
||||
content_height, line_surface, scroll_to_bottom, visible_window, LineStyle, TermMetrics,
|
||||
TermPalette, VisibleWindow,
|
||||
};
|
||||
@@ -0,0 +1,694 @@
|
||||
//! Modelo de selección del scrollback — base de la Fase 3 del SDD-TERMINAL.
|
||||
//!
|
||||
//! La selección se ancla por **(índice de línea en el store vigente, columna
|
||||
//! en bytes UTF-8 del texto de esa línea)** — no por id global ni por píxeles
|
||||
//! —, así sobrevive el append al fondo pero el caller debe descartarla si el
|
||||
//! frente del store se recortó (los índices se corren). El `Scrollback` ya
|
||||
//! expone `line_id`/`index_of_id` para que el caller traduzca antes/después
|
||||
//! del `drain` si quiere persistir la selección a través del recorte.
|
||||
//!
|
||||
//! Diseño:
|
||||
//!
|
||||
//! - `SelectionRange { anchor, head }`: dos puntos. `anchor` = donde empezó
|
||||
//! (press), `head` = donde está ahora (drag). `head == anchor` => selección
|
||||
//! vacía (cursor sin alcance).
|
||||
//! - `normalized()`: devuelve `(start, end)` con `start <= end`, **sin** mover
|
||||
//! el modelo (la UI quiere saber dónde está el cursor "vivo" para el caret,
|
||||
//! pero la extracción/painting necesita el rango ordenado).
|
||||
//! - `slice_text(store)`: extrae el texto seleccionado, una línea por
|
||||
//! renglón del store, recortado por columnas en la primera/última (clampeado
|
||||
//! a límites de char UTF-8).
|
||||
//!
|
||||
//! Sin dependencias de UI ni de wgpu — puro, testeable a mano. Las pintadas y
|
||||
//! el cableado de mouse vienen en commits siguientes (Fase 3 continúa).
|
||||
|
||||
use crate::blocks::{Item, ItemGeo};
|
||||
use crate::store::Scrollback;
|
||||
use crate::view::TermMetrics;
|
||||
|
||||
/// Un punto en el scrollback — un par `(idx_línea, col_byte)`. El índice es
|
||||
/// vigente en el store (post-recortes); la columna es offset **en bytes** del
|
||||
/// texto de esa línea. Se clampea al largo real al usar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Point {
|
||||
/// Índice 0-based de la línea en el store vigente.
|
||||
pub line: usize,
|
||||
/// Offset en bytes dentro del texto de la línea (clampeado a límite UTF-8).
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
pub const fn new(line: usize, col: usize) -> Self {
|
||||
Self { line, col }
|
||||
}
|
||||
}
|
||||
|
||||
/// Una selección viva — `anchor` (press) y `head` (drag actual). Convertir
|
||||
/// a `(start, end)` ordenado con [`Self::normalized`] antes de pintar o
|
||||
/// extraer texto.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SelectionRange {
|
||||
pub anchor: Point,
|
||||
pub head: Point,
|
||||
}
|
||||
|
||||
impl SelectionRange {
|
||||
/// Selección colapsada (cursor sin alcance) en `p`.
|
||||
pub const fn collapsed(p: Point) -> Self {
|
||||
Self { anchor: p, head: p }
|
||||
}
|
||||
|
||||
/// `true` si la selección no cubre ningún byte.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.anchor == self.head
|
||||
}
|
||||
|
||||
/// Devuelve `(start, end)` con `start <= end` en orden lexicográfico
|
||||
/// `(line, col)`. **No** mueve el modelo — el caller decide si quiere
|
||||
/// el ancla por separado del head (para el caret).
|
||||
pub fn normalized(&self) -> (Point, Point) {
|
||||
let a = self.anchor;
|
||||
let b = self.head;
|
||||
if (a.line, a.col) <= (b.line, b.col) {
|
||||
(a, b)
|
||||
} else {
|
||||
(b, a)
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` si la selección toca el renglón `line` (alguna parte del
|
||||
/// rango está sobre esa línea). Útil para el painter de la ventana
|
||||
/// visible: itera filas y pinta el highlight sólo donde aplica.
|
||||
pub fn touches_line(&self, line: usize) -> bool {
|
||||
let (s, e) = self.normalized();
|
||||
line >= s.line && line <= e.line
|
||||
}
|
||||
|
||||
/// Rango de columnas `(start_col, end_col_exclusive)` que la selección
|
||||
/// cubre en la línea `line` cuyo texto tiene `text_len` bytes.
|
||||
/// Para líneas intermedias: `(0, text_len)`. Para la primera/última:
|
||||
/// recorta. Si la selección no toca esta línea: `None`.
|
||||
pub fn col_range_on(&self, line: usize, text_len: usize) -> Option<(usize, usize)> {
|
||||
let (s, e) = self.normalized();
|
||||
if line < s.line || line > e.line {
|
||||
return None;
|
||||
}
|
||||
let start = if line == s.line { s.col.min(text_len) } else { 0 };
|
||||
let end = if line == e.line {
|
||||
e.col.min(text_len)
|
||||
} else {
|
||||
text_len
|
||||
};
|
||||
// Si el rango es vacío (selección colapsada justo en límite) → None,
|
||||
// para que el painter no dibuje un highlight de 0 bytes.
|
||||
if start >= end {
|
||||
return None;
|
||||
}
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Extrae el texto seleccionado del `store`. Multi-línea: las líneas
|
||||
/// intermedias enteras, la primera/última recortadas por columna.
|
||||
/// Columnas se clampean al límite de char UTF-8 más cercano hacia abajo
|
||||
/// (no panic si caen a media codepoint). Líneas fuera del store
|
||||
/// vigente se ignoran. Selección vacía → string vacío.
|
||||
pub fn slice_text(&self, store: &Scrollback) -> String {
|
||||
if self.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let (s, e) = self.normalized();
|
||||
if store.len() == 0 || s.line >= store.len() {
|
||||
return String::new();
|
||||
}
|
||||
let last_line = e.line.min(store.len().saturating_sub(1));
|
||||
let mut out = String::new();
|
||||
for line in s.line..=last_line {
|
||||
let Some(text) = store.line(line) else {
|
||||
continue;
|
||||
};
|
||||
let (a, b) = if line == s.line && line == e.line {
|
||||
(clamp_char_boundary(text, s.col), clamp_char_boundary(text, e.col))
|
||||
} else if line == s.line {
|
||||
(clamp_char_boundary(text, s.col), text.len())
|
||||
} else if line == last_line {
|
||||
(0, clamp_char_boundary(text, e.col))
|
||||
} else {
|
||||
(0, text.len())
|
||||
};
|
||||
if a < b {
|
||||
out.push_str(&text[a..b]);
|
||||
}
|
||||
if line != last_line {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Clampea `col` hacia abajo hasta el primer límite de char UTF-8 ≤ `col`.
|
||||
/// Si `col >= text.len()` devuelve `text.len()`. Garantiza que `text[..ret]`
|
||||
/// sea un slice válido.
|
||||
fn clamp_char_boundary(text: &str, col: usize) -> usize {
|
||||
if col >= text.len() {
|
||||
return text.len();
|
||||
}
|
||||
let mut c = col;
|
||||
while c > 0 && !text.is_char_boundary(c) {
|
||||
c -= 1;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Convierte coords `(lx, ly)` del viewport del `block_surface` a un
|
||||
/// [`Point`] del store (línea + columna en bytes UTF-8). **Puro**: replica
|
||||
/// la geometría del render (mismo `item_tops` + `visible_rows_in_item` que
|
||||
/// la pintada) para que el caret/anchor caigan exactamente donde el usuario
|
||||
/// hizo click. `(lx, ly)` son **relativas al viewport** (origen = esquina
|
||||
/// superior-izquierda del rect del widget). Devuelve `None` si `ly` cae en
|
||||
/// un item `Chrome` (los chrome no son seleccionables) o fuera del stream.
|
||||
///
|
||||
/// La conversión visual_col → byte_col cuenta chars del texto: para mono
|
||||
/// asume 1 cell por char (CJK doble queda fuera del MVP). Si el click cae
|
||||
/// más allá del fin del texto, snapea al fin.
|
||||
pub fn point_at<Msg>(
|
||||
items: &[Item<Msg>],
|
||||
scroll_y: f32,
|
||||
viewport_h: f32,
|
||||
metrics: TermMetrics,
|
||||
gutter_w: f32,
|
||||
store: &Scrollback,
|
||||
lx: f32,
|
||||
ly: f32,
|
||||
) -> Option<Point> {
|
||||
// Wrapper sobre `point_at_geo` que extrae la geometría liviana de cada
|
||||
// item. Práctico para callers que aún tienen el `Vec<Item>` a mano.
|
||||
let geo: Vec<ItemGeo> = items.iter().map(|it| it.geo()).collect();
|
||||
point_at_geo(&geo, scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)
|
||||
}
|
||||
|
||||
/// Como [`point_at`] pero contra `&[ItemGeo]` — lo que el caller puede
|
||||
/// stashear de un frame a otro (es `Copy`, no carga `View`s). Útil para que
|
||||
/// el `update` resuelva clicks contra el layout del render previo sin
|
||||
/// re-armar los items.
|
||||
pub fn point_at_geo(
|
||||
items: &[ItemGeo],
|
||||
scroll_y: f32,
|
||||
viewport_h: f32,
|
||||
metrics: TermMetrics,
|
||||
gutter_w: f32,
|
||||
store: &Scrollback,
|
||||
lx: f32,
|
||||
ly: f32,
|
||||
) -> Option<Point> {
|
||||
if viewport_h <= 0.0 || metrics.line_height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let row_h = metrics.line_height;
|
||||
let char_w = metrics.char_width.max(0.5);
|
||||
let content_y = scroll_y + ly.max(0.0);
|
||||
|
||||
let mut item_top = 0.0_f32;
|
||||
for it in items {
|
||||
let item_h = it.height(row_h);
|
||||
let item_bottom = item_top + item_h;
|
||||
if content_y >= item_top && content_y < item_bottom {
|
||||
match it {
|
||||
ItemGeo::Chrome(_) => return None,
|
||||
ItemGeo::Lines(start, end) => {
|
||||
let nrows = end.saturating_sub(*start);
|
||||
if nrows == 0 {
|
||||
return None;
|
||||
}
|
||||
let k = (((content_y - item_top) / row_h).floor() as usize).min(nrows - 1);
|
||||
let line = start + k;
|
||||
let text = store.line(line).unwrap_or("");
|
||||
// Mismo offset que usa `text_row` al pintar el texto
|
||||
// (gutter + 4 px de padding); sin esto el byte_col
|
||||
// copiado quedaba a ~½ char a la izquierda del click.
|
||||
let vis_x =
|
||||
(lx - gutter_w - crate::blocks::TEXT_LEFT_PADDING_PX).max(0.0);
|
||||
let vis_col = (vis_x / char_w).floor() as usize;
|
||||
let byte_col = visual_to_byte_col(text, vis_col);
|
||||
return Some(Point::new(line, byte_col));
|
||||
}
|
||||
}
|
||||
}
|
||||
item_top = item_bottom;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Convierte una columna visual (índice de char, 0-based) en una columna
|
||||
/// de bytes dentro de `text`. Si la visual cae más allá del último char,
|
||||
/// devuelve `text.len()`. Pensado para hit-test de mouse en mono.
|
||||
fn visual_to_byte_col(text: &str, vis_col: usize) -> usize {
|
||||
let mut chars_seen = 0;
|
||||
for (b, _c) in text.char_indices() {
|
||||
if chars_seen == vis_col {
|
||||
return b;
|
||||
}
|
||||
chars_seen += 1;
|
||||
}
|
||||
text.len()
|
||||
}
|
||||
|
||||
/// Un rectángulo de highlight para pintar — coords **relativas al viewport**
|
||||
/// del `block_surface` (origen = esquina superior-izquierda del rect del
|
||||
/// widget, ya descontado `scroll_y`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct HighlightRect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
/// Calcula los rectángulos de highlight que pinta una selección sobre la
|
||||
/// ventana visible de un `block_surface`. **Puro**: no depende de wgpu ni
|
||||
/// de Views — devuelve geometría que el painter del widget consume con
|
||||
/// `scene.fill`. El caller pasa `gutter_w` (típicamente vía
|
||||
/// [`crate::blocks::gutter_width`]) y las métricas de la superficie.
|
||||
///
|
||||
/// Sólo emite rects para líneas que (a) caen dentro de un `Item::Lines` del
|
||||
/// stream y (b) intersectan el viewport. Items `Chrome` no entran (el chrome
|
||||
/// es opaco y el caller decide su propio highlight si lo necesita).
|
||||
///
|
||||
/// Las columnas en `SelectionRange` son **bytes UTF-8**; el rect se calcula
|
||||
/// en **columnas visuales** (chars contados, mono = 1 cell por char). CJK
|
||||
/// ancho doble queda fuera del MVP — emite rects de 1 cell por char.
|
||||
pub fn selection_rects<Msg>(
|
||||
items: &[Item<Msg>],
|
||||
scroll_y: f32,
|
||||
viewport_h: f32,
|
||||
metrics: TermMetrics,
|
||||
gutter_w: f32,
|
||||
store: &Scrollback,
|
||||
sel: &SelectionRange,
|
||||
) -> Vec<HighlightRect> {
|
||||
if sel.is_empty() || viewport_h <= 0.0 || metrics.line_height <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let row_h = metrics.line_height;
|
||||
let char_w = metrics.char_width.max(0.5);
|
||||
let mut out: Vec<HighlightRect> = Vec::new();
|
||||
|
||||
let mut item_top = 0.0_f32;
|
||||
for it in items {
|
||||
let item_h = it.height(row_h);
|
||||
let item_bottom = item_top + item_h;
|
||||
// Skip items totalmente fuera del viewport.
|
||||
if item_bottom <= scroll_y || item_top >= scroll_y + viewport_h {
|
||||
item_top = item_bottom;
|
||||
continue;
|
||||
}
|
||||
if let Item::Lines { start, end } = it {
|
||||
let nrows = end.saturating_sub(*start);
|
||||
if nrows == 0 {
|
||||
item_top = item_bottom;
|
||||
continue;
|
||||
}
|
||||
// Sub-filas dentro del item que tocan el viewport (locales 0-based).
|
||||
let off = scroll_y;
|
||||
let k0 = (((off - item_top) / row_h).floor().max(0.0) as usize).min(nrows);
|
||||
let k1 = (((off + viewport_h - item_top) / row_h).ceil().max(0.0) as usize).min(nrows);
|
||||
for k in k0..k1 {
|
||||
let idx = start + k;
|
||||
if !sel.touches_line(idx) {
|
||||
continue;
|
||||
}
|
||||
let Some(text) = store.line(idx) else { continue };
|
||||
let Some((a, b)) = sel.col_range_on(idx, text.len()) else { continue };
|
||||
// Snap a límites UTF-8 (defensa; col_range_on ya clampa a len).
|
||||
let a_safe = clamp_char_boundary(text, a);
|
||||
let b_safe = clamp_char_boundary(text, b);
|
||||
if a_safe >= b_safe {
|
||||
continue;
|
||||
}
|
||||
let vis_a = text[..a_safe].chars().count() as f32;
|
||||
let vis_b = text[..b_safe].chars().count() as f32;
|
||||
let row_y = item_top + k as f32 * row_h - scroll_y;
|
||||
// El texto se pinta a `gutter + TEXT_LEFT_PADDING_PX` —
|
||||
// el rect tiene que arrancar en el mismo offset.
|
||||
let text_x0 = gutter_w + crate::blocks::TEXT_LEFT_PADDING_PX;
|
||||
out.push(HighlightRect {
|
||||
x: text_x0 + vis_a * char_w,
|
||||
y: row_y,
|
||||
w: (vis_b - vis_a) * char_w,
|
||||
h: row_h,
|
||||
});
|
||||
}
|
||||
}
|
||||
item_top = item_bottom;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store_of(lines: &[&str]) -> Scrollback {
|
||||
let mut s = Scrollback::new(0);
|
||||
for l in lines {
|
||||
s.push_line(l);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_is_empty_and_yields_empty_slice() {
|
||||
let sel = SelectionRange::collapsed(Point::new(0, 0));
|
||||
assert!(sel.is_empty());
|
||||
let store = store_of(&["hola", "mundo"]);
|
||||
assert_eq!(sel.slice_text(&store), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_swaps_when_head_before_anchor() {
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(3, 7),
|
||||
head: Point::new(1, 2),
|
||||
};
|
||||
let (s, e) = sel.normalized();
|
||||
assert_eq!(s, Point::new(1, 2));
|
||||
assert_eq!(e, Point::new(3, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_slice_recorta_por_columnas() {
|
||||
let store = store_of(&["the quick brown fox"]);
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 4),
|
||||
head: Point::new(0, 9),
|
||||
};
|
||||
assert_eq!(sel.slice_text(&store), "quick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_line_slice_incluye_lineas_intermedias_completas() {
|
||||
let store = store_of(&["uno dos", "tres cuatro", "cinco seis"]);
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 4),
|
||||
head: Point::new(2, 5),
|
||||
};
|
||||
// De "dos" en línea 0 (col 4..7), TODA línea 1, hasta "cinco" en línea 2.
|
||||
assert_eq!(sel.slice_text(&store), "dos\ntres cuatro\ncinco");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn col_range_on_recorta_solo_primera_y_ultima() {
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 4),
|
||||
head: Point::new(2, 5),
|
||||
};
|
||||
assert_eq!(sel.col_range_on(0, 7), Some((4, 7))); // primera: recorta start
|
||||
assert_eq!(sel.col_range_on(1, 11), Some((0, 11))); // intermedia: línea entera
|
||||
assert_eq!(sel.col_range_on(2, 10), Some((0, 5))); // última: recorta end
|
||||
assert_eq!(sel.col_range_on(3, 10), None); // fuera
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn col_range_on_descarta_rango_vacio() {
|
||||
// Si la selección termina en col 0 de una línea, su contribución a esa
|
||||
// línea es 0 bytes → no se debe pintar nada.
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 4),
|
||||
head: Point::new(1, 0),
|
||||
};
|
||||
assert_eq!(sel.col_range_on(1, 5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touches_line_chequea_rango_inclusivo() {
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(2, 0),
|
||||
head: Point::new(4, 0),
|
||||
};
|
||||
assert!(!sel.touches_line(1));
|
||||
assert!(sel.touches_line(2));
|
||||
assert!(sel.touches_line(3));
|
||||
assert!(sel.touches_line(4));
|
||||
assert!(!sel.touches_line(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_clampa_col_fuera_de_texto() {
|
||||
// col más allá del largo del texto → recorta al fin del texto, sin panic.
|
||||
let store = store_of(&["hi"]);
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 0),
|
||||
head: Point::new(0, 999),
|
||||
};
|
||||
assert_eq!(sel.slice_text(&store), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_respeta_limites_utf8() {
|
||||
// "héllo" — la 'é' es 2 bytes (0xC3 0xA9). Col 2 cae a mitad de char;
|
||||
// debe redondear hacia abajo a col 1 (después de 'h'), no panic.
|
||||
let store = store_of(&["héllo"]);
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 0),
|
||||
head: Point::new(0, 2),
|
||||
};
|
||||
// col 2 → boundary 1 (después de 'h'); slice "h".
|
||||
assert_eq!(sel.slice_text(&store), "h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_clampa_lineas_fuera_del_store() {
|
||||
// El store tiene 2 líneas; la selección termina en la 5 → recorta a la 1.
|
||||
let store = store_of(&["uno", "dos"]);
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 0),
|
||||
head: Point::new(5, 999),
|
||||
};
|
||||
assert_eq!(sel.slice_text(&store), "uno\ndos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_de_seleccion_vacia_es_vacio_aun_con_anchor_no_nulo() {
|
||||
// anchor == head → vacío, aún si están en (3, 5).
|
||||
let store = store_of(&["abcd", "efgh", "ijkl", "mnop"]);
|
||||
let sel = SelectionRange::collapsed(Point::new(3, 2));
|
||||
assert_eq!(sel.slice_text(&store), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_sobre_store_vacio_es_vacio() {
|
||||
let store = Scrollback::new(0);
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 0),
|
||||
head: Point::new(2, 5),
|
||||
};
|
||||
assert_eq!(sel.slice_text(&store), "");
|
||||
}
|
||||
|
||||
fn rects<Msg>(
|
||||
items: &[Item<Msg>],
|
||||
scroll_y: f32,
|
||||
viewport_h: f32,
|
||||
gutter_w: f32,
|
||||
store: &Scrollback,
|
||||
sel: &SelectionRange,
|
||||
) -> Vec<HighlightRect> {
|
||||
let metrics = TermMetrics {
|
||||
font_size: 12.0,
|
||||
line_height: 16.0,
|
||||
char_width: 8.0,
|
||||
};
|
||||
selection_rects(items, scroll_y, viewport_h, metrics, gutter_w, store, sel)
|
||||
}
|
||||
|
||||
fn point(
|
||||
items: &[Item<()>],
|
||||
scroll_y: f32,
|
||||
gutter_w: f32,
|
||||
store: &Scrollback,
|
||||
lx: f32,
|
||||
ly: f32,
|
||||
) -> Option<Point> {
|
||||
let metrics = TermMetrics {
|
||||
font_size: 12.0,
|
||||
line_height: 16.0,
|
||||
char_width: 8.0,
|
||||
};
|
||||
point_at(items, scroll_y, 100.0, metrics, gutter_w, store, lx, ly)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_at_resuelve_linea_y_columna_para_un_click_simple() {
|
||||
let store = store_of(&["abcdef", "ghijkl"]);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 2)];
|
||||
// Click en línea 1 (y = 20 → fila 1 con row_h=16), col visual = (50-30)/8 = 2.
|
||||
let p = point(&items, 0.0, 30.0, &store, 50.0, 20.0).unwrap();
|
||||
assert_eq!(p, Point::new(1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_at_clampea_click_fuera_del_texto_al_fin_de_linea() {
|
||||
// Línea de 4 chars, click muy a la derecha → snap al fin (col 4).
|
||||
let store = store_of(&["abcd"]);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
|
||||
let p = point(&items, 0.0, 30.0, &store, 1000.0, 5.0).unwrap();
|
||||
assert_eq!(p, Point::new(0, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_at_en_el_gutter_cae_a_col_0() {
|
||||
// Click dentro del gutter (lx < gutter_w) → col visual = 0 → byte col = 0.
|
||||
let store = store_of(&["xyz"]);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
|
||||
let p = point(&items, 0.0, 30.0, &store, 10.0, 5.0).unwrap();
|
||||
assert_eq!(p, Point::new(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_at_devuelve_none_para_chrome_o_fuera() {
|
||||
// Item 0 = chrome (alto 24); item 1 = 2 líneas. Click en y=10 cae en chrome.
|
||||
let store = store_of(&["aa", "bb"]);
|
||||
let chrome_view: llimphi_ui::View<()> = llimphi_ui::View::new(Default::default());
|
||||
let items: Vec<Item<()>> = vec![Item::chrome(24.0, chrome_view), Item::lines(0, 2)];
|
||||
assert_eq!(point(&items, 0.0, 30.0, &store, 50.0, 10.0), None);
|
||||
// y > total: fuera del stream → None.
|
||||
assert_eq!(point(&items, 0.0, 30.0, &store, 50.0, 1000.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_at_respeta_scroll_y() {
|
||||
// 100 líneas; con scroll_y = 800, el click en y=8 cae en la línea
|
||||
// floor((800+8)/16) = 50.
|
||||
let lines: Vec<&str> = (0..100).map(|_| "ab").collect();
|
||||
let store = store_of(&lines);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 100)];
|
||||
let p = point(&items, 800.0, 30.0, &store, 30.0, 8.0).unwrap();
|
||||
assert_eq!(p, Point::new(50, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_at_convierte_visual_a_byte_para_utf8() {
|
||||
// "héllo": vis 0='h', vis 1='é' (2 bytes), vis 2='l' (byte 3), vis 3='l' (byte 4).
|
||||
let store = store_of(&["héllo"]);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
|
||||
// Click en vis col 2 (lx = 30 + 4 + 2*8 = 50) — el +4 es
|
||||
// TEXT_LEFT_PADDING_PX → byte col 3.
|
||||
let p = point(&items, 0.0, 30.0, &store, 50.0, 5.0).unwrap();
|
||||
assert_eq!(p, Point::new(0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rects_de_seleccion_vacia_son_vacio() {
|
||||
let store = store_of(&["abc"]);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
|
||||
let sel = SelectionRange::collapsed(Point::new(0, 1));
|
||||
assert_eq!(rects(&items, 0.0, 100.0, 30.0, &store, &sel), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_single_line_ubica_x_y_w_correctos() {
|
||||
// Línea 0 entera (3 chars). x = gutter + TEXT_LEFT_PADDING (4) + 0,
|
||||
// w = 3 * char_w. El +4 es el padding interno del text_row.
|
||||
let store = store_of(&["abc"]);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 0),
|
||||
head: Point::new(0, 3),
|
||||
};
|
||||
let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
|
||||
assert_eq!(r.len(), 1);
|
||||
let h = r[0];
|
||||
assert_eq!(h.x, 34.0); // 30 + 4
|
||||
assert_eq!(h.y, 0.0);
|
||||
assert_eq!(h.w, 24.0); // 3 * 8.0
|
||||
assert_eq!(h.h, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_multi_line_emite_uno_por_renglon() {
|
||||
// 3 líneas, selección abarca las 3 (primera/última recortadas).
|
||||
let store = store_of(&["alpha", "beta", "gamma"]);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 3)];
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 2), // "pha"
|
||||
head: Point::new(2, 3), // "gam"
|
||||
};
|
||||
let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
|
||||
assert_eq!(r.len(), 3);
|
||||
// Línea 0: chars 2..5 → x = 30 + 4 + 2*8 = 50, w = 3*8 = 24
|
||||
assert_eq!(r[0].x, 50.0);
|
||||
assert_eq!(r[0].w, 24.0);
|
||||
// Línea 1 entera: "beta" (4 chars).
|
||||
assert_eq!(r[1].x, 34.0);
|
||||
assert_eq!(r[1].w, 32.0);
|
||||
// Línea 2: chars 0..3 → "gam".
|
||||
assert_eq!(r[2].x, 34.0);
|
||||
assert_eq!(r[2].w, 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rects_descartan_lineas_fuera_del_viewport() {
|
||||
// 100 líneas, viewport 32 px (=2 filas), scroll a la mitad → sólo 2-3 rects.
|
||||
let lines: Vec<&str> = (0..100).map(|_| "row").collect();
|
||||
let store = store_of(&lines);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 100)];
|
||||
// Selección sobre TODAS las líneas, pero sólo 2-3 entran al viewport.
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 0),
|
||||
head: Point::new(99, 3),
|
||||
};
|
||||
// scroll a la fila 50 (50 * 16 = 800 px). Viewport de 32 px → filas
|
||||
// 50, 51 (+ guarda).
|
||||
let r = rects(&items, 800.0, 32.0, 30.0, &store, &sel);
|
||||
assert!(r.len() <= 3 && !r.is_empty(),
|
||||
"esperado ~2-3 rects, no {} (todas las líneas)", r.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rects_saltan_items_chrome() {
|
||||
// Item 0 = chrome (alto 20), item 1 = 2 líneas. Selección sobre las dos
|
||||
// líneas. El chrome no debe aportar rects.
|
||||
let store = store_of(&["aa", "bb"]);
|
||||
let chrome_view: llimphi_ui::View<()> = llimphi_ui::View::new(Default::default());
|
||||
let items: Vec<Item<()>> = vec![Item::chrome(20.0, chrome_view), Item::lines(0, 2)];
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 0),
|
||||
head: Point::new(1, 2),
|
||||
};
|
||||
let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
|
||||
assert_eq!(r.len(), 2);
|
||||
// El primer rect arranca DESPUÉS del chrome (y = 20).
|
||||
assert_eq!(r[0].y, 20.0);
|
||||
// El segundo está una fila más abajo (20 + 16 = 36).
|
||||
assert_eq!(r[1].y, 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rects_usan_visual_cols_no_bytes_para_utf8() {
|
||||
// "héllo" — 'é' es 2 bytes, pero 1 char visual. Selección de col 0 a
|
||||
// col 3 (byte) → snap a 3 (después de "hé"), 2 chars visuales.
|
||||
let store = store_of(&["héllo"]);
|
||||
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 0),
|
||||
head: Point::new(0, 3),
|
||||
};
|
||||
let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
|
||||
assert_eq!(r.len(), 1);
|
||||
// 2 chars visuales × 8 px = 16.
|
||||
assert_eq!(r[0].w, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_a_linea_intermedia_omite_las_que_no_existen() {
|
||||
// Si una línea intermedia desaparece (no debería pasar acá pero el
|
||||
// store sólo expone `line()`), se omite — no se inserta `\n` extra.
|
||||
// Acá lo cubrimos indirectamente con un store contiguo.
|
||||
let store = store_of(&["aa", "bb", "cc"]);
|
||||
let sel = SelectionRange {
|
||||
anchor: Point::new(0, 1),
|
||||
head: Point::new(2, 1),
|
||||
};
|
||||
assert_eq!(sel.slice_text(&store), "a\nbb\nc");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
//! Store de scrollback append-only (Capa 0 del SDD-TERMINAL).
|
||||
//!
|
||||
//! El texto vive en un buffer contiguo (`buf`) y un índice de offsets de inicio
|
||||
//! de línea (`starts`, con una sentinela al final) da acceso a la línea N en
|
||||
//! **O(1)**. El cap es por **MEMORIA** (bytes), no por número de líneas: al
|
||||
//! excederse, se descartan líneas enteras del **frente** en un solo `drain` +
|
||||
//! reindex (amortizado, no una vez por línea).
|
||||
//!
|
||||
//! Las líneas descartadas se **cuentan** (`dropped`), de modo que cada línea
|
||||
//! tiene un **id global estable** (`line_id = dropped + idx`) que sobrevive al
|
||||
//! recorte del frente. Eso permite anclar el scroll a un id (no a px desde el
|
||||
//! fondo) y preservar la posición de lectura mientras llega output —
|
||||
//! exactamente la deuda B del PLAN-OUTPUT, que acá nace resuelta de raíz.
|
||||
//!
|
||||
//! Una línea es **un renglón lógico sin `'\n'`** (el caller lo separa; en shuma
|
||||
//! cada `OutputLine` ya es una línea). El store no interpreta el contenido.
|
||||
|
||||
/// Límite de memoria por defecto del scrollback: 64 MiB ≈ cientos de miles de
|
||||
/// líneas. "Infinito" en la práctica = "acotado por una memoria que elegís".
|
||||
pub const DEFAULT_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Persistencia opcional de las líneas que el cap recorta del frente: en vez
|
||||
/// de tirarlas, las appendea a un archivo y guarda `(offset, len)` por línea
|
||||
/// para lookup random posterior. Es lo que habilita "scrollback infinito"
|
||||
/// (Fase 5 del SDD-TERMINAL) cuando el shell corre por horas y la memoria
|
||||
/// no alcanza para todo el output histórico.
|
||||
///
|
||||
/// Diseño:
|
||||
///
|
||||
/// - **Archivo append-only**: cada línea se escribe verbatim (sin separador
|
||||
/// intermedio — la longitud está en el índice). Crecimiento monótono.
|
||||
/// - **Índice en memoria** `(offset, len)` por línea, indexado por
|
||||
/// `global_id` (el mismo id estable del `Scrollback`). Random access O(1).
|
||||
/// - **UTF-8 in/out**: el caller pasa `&str`, el read devuelve `String`. Si
|
||||
/// el archivo se corrompe (improbable mientras nadie más lo toque), el
|
||||
/// read devuelve `InvalidData`.
|
||||
#[derive(Debug)]
|
||||
pub struct SpillStore {
|
||||
file: std::fs::File,
|
||||
/// `(offset_in_file, byte_len)` por línea spilleada, indexada por
|
||||
/// posición en este Vec (no por `global_id` — restamos `base_id` al
|
||||
/// indexar si en el futuro permitimos descartar también las viejas).
|
||||
entries: Vec<(u64, u32)>,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl SpillStore {
|
||||
/// Crea o abre un spill file en `path`. Si el archivo existe, lo trunca
|
||||
/// (el caller decide si quiere persistencia inter-sesión, generalmente
|
||||
/// no — el shell tipicamente arranca con un spill nuevo). Devuelve
|
||||
/// `Err` si no se puede crear (permisos, disco lleno).
|
||||
pub fn create(path: impl Into<std::path::PathBuf>) -> std::io::Result<Self> {
|
||||
let path = path.into();
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.read(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&path)?;
|
||||
Ok(Self {
|
||||
file,
|
||||
entries: Vec::new(),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Append de una línea al spill. Devuelve el índice (`entries.len()-1`)
|
||||
/// donde queda registrada. NO inserta separador — el largo está en el
|
||||
/// índice. Errores: `WriteZero`/`Interrupted` y otros transientes se
|
||||
/// devuelven; el caller puede decidir reintentar o ignorar.
|
||||
pub fn append(&mut self, text: &str) -> std::io::Result<usize> {
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
let offset = self.file.seek(SeekFrom::End(0))?;
|
||||
self.file.write_all(text.as_bytes())?;
|
||||
// Flush por seguridad — el shell puede crashear y queremos el
|
||||
// archivo legible. Cost ~µs en SSD; aceptable.
|
||||
self.file.flush()?;
|
||||
self.entries.push((offset, text.len() as u32));
|
||||
Ok(self.entries.len() - 1)
|
||||
}
|
||||
|
||||
/// Lee la línea spilleada con índice `i` (0-based dentro del spill, NO
|
||||
/// el `global_id`). `None` si fuera de rango.
|
||||
pub fn read(&mut self, i: usize) -> std::io::Result<Option<String>> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let Some(&(off, len)) = self.entries.get(i) else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.file.seek(SeekFrom::Start(off))?;
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
self.file.read_exact(&mut buf)?;
|
||||
String::from_utf8(buf).map(Some).map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
||||
})
|
||||
}
|
||||
|
||||
/// Cantidad de líneas spilleadas hasta ahora.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// `true` si todavía no spilleó ninguna línea.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Path del archivo del spill (informativo). El caller lo puede usar
|
||||
/// para mostrarlo en una notice tipo "salida volcada a /tmp/...".
|
||||
pub fn path(&self) -> &std::path::Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
/// Store de scrollback append-only con índice de líneas y cap por memoria.
|
||||
///
|
||||
/// Invariantes:
|
||||
/// - `starts` siempre tiene al menos un elemento (la sentinela) y es monótono
|
||||
/// creciente; `starts[len()] == buf.len()`.
|
||||
/// - `len() == starts.len() - 1`.
|
||||
/// - `line(i)` ⊆ `buf` para todo `i < len()`.
|
||||
#[derive(Debug)]
|
||||
pub struct Scrollback {
|
||||
/// Texto de todas las líneas vigentes, concatenado sin separadores.
|
||||
buf: String,
|
||||
/// `starts[i]` = offset de inicio de la línea `i` en `buf`. El último
|
||||
/// elemento es la sentinela (`== buf.len()`), así `line(i)` es
|
||||
/// `buf[starts[i]..starts[i+1]]` sin casos especiales para la última.
|
||||
starts: Vec<usize>,
|
||||
/// Cuántas líneas se descartaron del frente desde el último `clear`. Hace
|
||||
/// estable la numeración/los ids globales aunque el frente se recorte.
|
||||
dropped: u64,
|
||||
/// Cap de memoria del texto (`buf.len()`), en bytes.
|
||||
limit_bytes: usize,
|
||||
/// Spill opcional: cuando se setea, las líneas que `enforce_limit` saca
|
||||
/// del frente NO se pierden — se appendean al spill y quedan
|
||||
/// recuperables vía `read_spilled` (Fase 5 del SDD-TERMINAL). El
|
||||
/// `Arc<Mutex<>>` deja que el `Scrollback` sea Clone aunque
|
||||
/// `SpillStore` no lo sea (file handles no son Clone).
|
||||
spill: Option<std::sync::Arc<std::sync::Mutex<SpillStore>>>,
|
||||
}
|
||||
|
||||
impl Clone for Scrollback {
|
||||
fn clone(&self) -> Self {
|
||||
// Clone share el spill (Arc) — las dos instancias appendean al MISMO
|
||||
// archivo, lo que es lo único razonable: el spill es la verdad
|
||||
// sobre las líneas viejas, no hay forma de "clonar el archivo".
|
||||
Self {
|
||||
buf: self.buf.clone(),
|
||||
starts: self.starts.clone(),
|
||||
dropped: self.dropped,
|
||||
limit_bytes: self.limit_bytes,
|
||||
spill: self.spill.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Scrollback {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_LIMIT_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
impl Scrollback {
|
||||
/// Store vacío con un cap de memoria explícito (bytes del texto). Un
|
||||
/// `limit_bytes` de `0` se trata como "sin tope práctico" (no recorta).
|
||||
pub fn new(limit_bytes: usize) -> Self {
|
||||
Self {
|
||||
buf: String::new(),
|
||||
starts: vec![0],
|
||||
dropped: 0,
|
||||
limit_bytes,
|
||||
spill: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Habilita el spill: las líneas que se recorten del frente se
|
||||
/// appendean a `spill` en vez de descartarse. El caller construye el
|
||||
/// `SpillStore` con `SpillStore::create(path)`.
|
||||
pub fn enable_spill(&mut self, spill: SpillStore) {
|
||||
self.spill = Some(std::sync::Arc::new(std::sync::Mutex::new(spill)));
|
||||
}
|
||||
|
||||
/// `true` si este scrollback tiene spill activo.
|
||||
pub fn has_spill(&self) -> bool {
|
||||
self.spill.is_some()
|
||||
}
|
||||
|
||||
/// Cantidad de líneas spilleadas hasta ahora (`0` si no hay spill).
|
||||
pub fn spilled_count(&self) -> usize {
|
||||
match self.spill.as_ref() {
|
||||
Some(s) => s.lock().map(|g| g.len()).unwrap_or(0),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Path del archivo de spill, si está activo. Lo expone el shell con
|
||||
/// `:scrollback` para que el usuario pueda abrirlo / `cat`-earlo /
|
||||
/// buscar grep en él. `None` si no hay spill.
|
||||
pub fn spill_path(&self) -> Option<std::path::PathBuf> {
|
||||
self.spill
|
||||
.as_ref()
|
||||
.and_then(|s| s.lock().ok().map(|g| g.path().to_path_buf()))
|
||||
}
|
||||
|
||||
/// Lee una línea spilleada por su id global. Lookup O(1) en el índice
|
||||
/// del spill + un `seek` + `read` en el archivo. Devuelve `None` si
|
||||
/// `global_id >= spilled_count` o no hay spill. La línea sigue contando
|
||||
/// con `dropped`/`total_pushed` originales (el id global persiste).
|
||||
pub fn read_spilled(&self, global_id: u64) -> std::io::Result<Option<String>> {
|
||||
let Some(spill) = self.spill.as_ref() else { return Ok(None) };
|
||||
let mut guard = match spill.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
// El spill indexa por orden de append, que coincide con el
|
||||
// global_id 0-based (la primera dropped es la 0ª, la segunda la
|
||||
// 1ª, etc.).
|
||||
guard.read(global_id as usize)
|
||||
}
|
||||
|
||||
/// Appendea **un renglón lógico** (sin `'\n'`; si lo trae, se guarda
|
||||
/// verbatim — el caller separa). Tras appendear, recorta el frente si el
|
||||
/// texto excede `limit_bytes`.
|
||||
pub fn push_line(&mut self, text: &str) {
|
||||
self.buf.push_str(text);
|
||||
self.starts.push(self.buf.len());
|
||||
self.enforce_limit();
|
||||
}
|
||||
|
||||
/// Recorta líneas enteras del frente hasta que `buf.len() <= limit_bytes`,
|
||||
/// en un solo `drain` + reindex. No-op si `limit_bytes == 0` o ya cabe.
|
||||
fn enforce_limit(&mut self) {
|
||||
if self.limit_bytes == 0 || self.buf.len() <= self.limit_bytes {
|
||||
return;
|
||||
}
|
||||
// Bytes que sobran respecto del tope: hay que liberar al menos esto del
|
||||
// frente. Buscamos el primer `k` cuyo offset de inicio deje `buf` bajo
|
||||
// el tope (`buf.len() - starts[k] <= limit`, i.e. `starts[k] >=
|
||||
// need_free`).
|
||||
let need_free = self.buf.len() - self.limit_bytes;
|
||||
let k = self.starts.partition_point(|&s| s < need_free);
|
||||
// No tirar la sentinela: como mucho dejamos el store vacío (línea única
|
||||
// más grande que el cap entero, caso patológico).
|
||||
let k = k.min(self.len());
|
||||
if k == 0 {
|
||||
return;
|
||||
}
|
||||
let cut = self.starts[k];
|
||||
// Antes de borrar las líneas del frente, las spillamos a disco si
|
||||
// hay spill configurado. El append es por línea (cada una con su
|
||||
// longitud en el índice), así el read random por `global_id`
|
||||
// sigue trivial.
|
||||
if let Some(spill) = self.spill.as_ref() {
|
||||
let mut guard = match spill.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
for i in 0..k {
|
||||
let lo = self.starts[i];
|
||||
let hi = self.starts[i + 1];
|
||||
// Errores de I/O se ignoran silenciosamente: el spill es
|
||||
// mejor-esfuerzo; perder una línea spilleada por disco
|
||||
// lleno no debe colgar el shell. El caller que quiera
|
||||
// chequear que el spill esté vivo puede mirar `spilled_count`
|
||||
// vs `dropped` y avisar al usuario.
|
||||
let _ = guard.append(&self.buf[lo..hi]);
|
||||
}
|
||||
}
|
||||
self.buf.drain(0..cut);
|
||||
self.starts.drain(0..k);
|
||||
for s in &mut self.starts {
|
||||
*s -= cut;
|
||||
}
|
||||
self.dropped += k as u64;
|
||||
}
|
||||
|
||||
/// Cantidad de líneas vigentes en el store.
|
||||
pub fn len(&self) -> usize {
|
||||
self.starts.len() - 1
|
||||
}
|
||||
|
||||
/// `true` si no hay líneas vigentes.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Línea `idx` (0-based, vigente) en **O(1)**. `None` fuera de rango.
|
||||
pub fn line(&self, idx: usize) -> Option<&str> {
|
||||
if idx + 1 >= self.starts.len() {
|
||||
return None;
|
||||
}
|
||||
Some(&self.buf[self.starts[idx]..self.starts[idx + 1]])
|
||||
}
|
||||
|
||||
/// Líneas descartadas del frente desde el último `clear`.
|
||||
pub fn dropped(&self) -> u64 {
|
||||
self.dropped
|
||||
}
|
||||
|
||||
/// Total de líneas que pasaron por el store desde el último `clear`
|
||||
/// (`dropped + len`). Es el número de la próxima línea (0-based global).
|
||||
pub fn total_pushed(&self) -> u64 {
|
||||
self.dropped + self.len() as u64
|
||||
}
|
||||
|
||||
/// Número **global 1-based** de la línea `idx` (para la numeración del
|
||||
/// gutter): estable aunque el frente se recorte.
|
||||
pub fn line_number(&self, idx: usize) -> u64 {
|
||||
self.dropped + idx as u64 + 1
|
||||
}
|
||||
|
||||
/// Id **global estable** de la línea `idx` (`dropped + idx`): sobrevive al
|
||||
/// recorte del frente. Para anclar el scroll a una línea concreta.
|
||||
pub fn line_id(&self, idx: usize) -> u64 {
|
||||
self.dropped + idx as u64
|
||||
}
|
||||
|
||||
/// Índice vigente del id global `id`, si la línea sigue en el store
|
||||
/// (no se recortó del frente ni es futura). `None` si no.
|
||||
pub fn index_of_id(&self, id: u64) -> Option<usize> {
|
||||
if id < self.dropped {
|
||||
return None;
|
||||
}
|
||||
let idx = (id - self.dropped) as usize;
|
||||
(idx < self.len()).then_some(idx)
|
||||
}
|
||||
|
||||
/// Bytes del texto vigente (lo que cuenta para el cap).
|
||||
pub fn byte_len(&self) -> usize {
|
||||
self.buf.len()
|
||||
}
|
||||
|
||||
/// Cap de memoria configurado.
|
||||
pub fn limit_bytes(&self) -> usize {
|
||||
self.limit_bytes
|
||||
}
|
||||
|
||||
/// Texto de las líneas `[start, end)` unido por `'\n'` — para copiar al
|
||||
/// clipboard una selección de filas. Recorta `end` a `len()`; rango vacío o
|
||||
/// invertido → cadena vacía.
|
||||
pub fn slice_text(&self, start: usize, end: usize) -> String {
|
||||
let end = end.min(self.len());
|
||||
if start >= end {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::with_capacity(self.starts[end] - self.starts[start]);
|
||||
for i in start..end {
|
||||
if i > start {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(self.line(i).unwrap_or(""));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Vacía el store y **reinicia** la numeración (`dropped = 0`) — el
|
||||
/// equivalente del builtin `clear` del shell: se empieza de cero.
|
||||
pub fn clear(&mut self) {
|
||||
self.buf.clear();
|
||||
self.starts.clear();
|
||||
self.starts.push(0);
|
||||
self.dropped = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_store_is_empty() {
|
||||
let s = Scrollback::new(1024);
|
||||
assert!(s.is_empty());
|
||||
assert_eq!(s.len(), 0);
|
||||
assert_eq!(s.line(0), None);
|
||||
assert_eq!(s.byte_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_and_access_o1() {
|
||||
let mut s = Scrollback::new(1024);
|
||||
s.push_line("uno");
|
||||
s.push_line("dos");
|
||||
s.push_line("tres");
|
||||
assert_eq!(s.len(), 3);
|
||||
assert_eq!(s.line(0), Some("uno"));
|
||||
assert_eq!(s.line(1), Some("dos"));
|
||||
assert_eq!(s.line(2), Some("tres"));
|
||||
assert_eq!(s.line(3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_are_one_based_global() {
|
||||
let mut s = Scrollback::new(1024);
|
||||
s.push_line("a");
|
||||
s.push_line("b");
|
||||
assert_eq!(s.line_number(0), 1);
|
||||
assert_eq!(s.line_number(1), 2);
|
||||
assert_eq!(s.line_id(0), 0);
|
||||
assert_eq!(s.line_id(1), 1);
|
||||
assert_eq!(s.total_pushed(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_drops_front_and_keeps_global_numbering() {
|
||||
// Tope chico: ~ cada línea ocupa "linea_N" (7-8 bytes). Con tope 20 sólo
|
||||
// entran ~2-3 líneas; las viejas se recortan del frente.
|
||||
let mut s = Scrollback::new(20);
|
||||
for i in 0..50 {
|
||||
s.push_line(&format!("L{i:04}")); // 5 bytes c/u
|
||||
}
|
||||
// Sigue bajo el tope.
|
||||
assert!(s.byte_len() <= 20, "byte_len {} excede el tope", s.byte_len());
|
||||
// Hubo recorte del frente.
|
||||
assert!(s.dropped() > 0);
|
||||
// La numeración global sigue siendo correcta: la última línea es la 50ª
|
||||
// (1-based), id global 49.
|
||||
let last = s.len() - 1;
|
||||
assert_eq!(s.line(last), Some("L0049"));
|
||||
assert_eq!(s.line_number(last), 50);
|
||||
assert_eq!(s.line_id(last), 49);
|
||||
// total_pushed cuenta todo lo que pasó (49 dropped + len vigente = 50).
|
||||
assert_eq!(s.total_pushed(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_lines_are_not_accessible_but_ids_resolve() {
|
||||
let mut s = Scrollback::new(20);
|
||||
for i in 0..50 {
|
||||
s.push_line(&format!("L{i:04}"));
|
||||
}
|
||||
let dropped = s.dropped();
|
||||
assert!(dropped > 0);
|
||||
// Un id ya recortado no resuelve a índice vigente.
|
||||
assert_eq!(s.index_of_id(0), None);
|
||||
// El id de la primera línea vigente resuelve a índice 0.
|
||||
let first_id = s.line_id(0);
|
||||
assert_eq!(first_id, dropped);
|
||||
assert_eq!(s.index_of_id(first_id), Some(0));
|
||||
// Un id futuro tampoco resuelve.
|
||||
assert_eq!(s.index_of_id(s.total_pushed() + 5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_survives_front_drop() {
|
||||
// Un id apuntado antes de un recorte sigue apuntando a la MISMA línea
|
||||
// (mientras siga vigente), aunque su índice cambie.
|
||||
let mut s = Scrollback::new(40);
|
||||
for i in 0..10 {
|
||||
s.push_line(&format!("L{i:04}"));
|
||||
}
|
||||
// Tomamos el id de una línea concreta por su texto.
|
||||
let idx = (0..s.len()).find(|&i| s.line(i) == Some("L0007")).unwrap();
|
||||
let id = s.line_id(idx);
|
||||
// Llega más output → se recorta más frente.
|
||||
for i in 10..20 {
|
||||
s.push_line(&format!("L{i:04}"));
|
||||
}
|
||||
// El id sigue resolviendo a la línea "L0007" si no fue recortada.
|
||||
if let Some(now) = s.index_of_id(id) {
|
||||
assert_eq!(s.line(now), Some("L0007"), "el id debe seguir apuntando a la misma línea");
|
||||
}
|
||||
// (Si "L0007" ya se recortó, index_of_id devuelve None — también válido.)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_text_joins_with_newlines() {
|
||||
let mut s = Scrollback::new(1024);
|
||||
for l in ["alfa", "beta", "gamma", "delta"] {
|
||||
s.push_line(l);
|
||||
}
|
||||
assert_eq!(s.slice_text(1, 3), "beta\ngamma");
|
||||
assert_eq!(s.slice_text(0, 4), "alfa\nbeta\ngamma\ndelta");
|
||||
// Rango clampeado y vacío.
|
||||
assert_eq!(s.slice_text(2, 999), "gamma\ndelta");
|
||||
assert_eq!(s.slice_text(3, 3), "");
|
||||
assert_eq!(s.slice_text(5, 2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_resets_buffer_and_numbering() {
|
||||
let mut s = Scrollback::new(20);
|
||||
for i in 0..50 {
|
||||
s.push_line(&format!("L{i:04}"));
|
||||
}
|
||||
assert!(s.dropped() > 0);
|
||||
s.clear();
|
||||
assert!(s.is_empty());
|
||||
assert_eq!(s.dropped(), 0);
|
||||
assert_eq!(s.byte_len(), 0);
|
||||
// Tras clear la numeración arranca de nuevo en 1.
|
||||
s.push_line("nuevo");
|
||||
assert_eq!(s.line_number(0), 1);
|
||||
assert_eq!(s.line(0), Some("nuevo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_limit_means_no_cap() {
|
||||
let mut s = Scrollback::new(0);
|
||||
for i in 0..1000 {
|
||||
s.push_line(&format!("linea {i}"));
|
||||
}
|
||||
assert_eq!(s.len(), 1000);
|
||||
assert_eq!(s.dropped(), 0);
|
||||
assert_eq!(s.line(999), Some("linea 999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_lines_are_sliced_on_char_boundaries() {
|
||||
// El índice usa offsets de byte; appendeamos líneas completas, así que
|
||||
// los cortes caen siempre en frontera de carácter (inicio de línea).
|
||||
let mut s = Scrollback::new(1024);
|
||||
s.push_line("café ☕");
|
||||
s.push_line("niño ñ");
|
||||
assert_eq!(s.line(0), Some("café ☕"));
|
||||
assert_eq!(s.line(1), Some("niño ñ"));
|
||||
assert_eq!(s.slice_text(0, 2), "café ☕\nniño ñ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_archiva_lineas_recortadas_y_las_lee_random() {
|
||||
// Setup: store con cap chico + spill a un archivo temporal. Tras
|
||||
// muchas appends, las líneas viejas viven en el spill y se leen
|
||||
// de vuelta por su id global.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("spill.log");
|
||||
let spill = SpillStore::create(&path).expect("spill create");
|
||||
let mut s = Scrollback::new(20);
|
||||
s.enable_spill(spill);
|
||||
assert!(s.has_spill());
|
||||
|
||||
for i in 0..50 {
|
||||
s.push_line(&format!("L{i:04}"));
|
||||
}
|
||||
// Hubo recorte → spill tiene entries.
|
||||
assert!(s.dropped() > 0);
|
||||
assert_eq!(s.spilled_count() as u64, s.dropped(), "todas las dropped van al spill");
|
||||
// Una línea concreta del spill — la 5ª (id=5) → "L0005".
|
||||
let read = s.read_spilled(5).expect("read").expect("entry");
|
||||
assert_eq!(read, "L0005");
|
||||
// La primera (id=0).
|
||||
let first = s.read_spilled(0).expect("read").expect("entry");
|
||||
assert_eq!(first, "L0000");
|
||||
// Una línea fuera de rango (un id futuro).
|
||||
let none = s.read_spilled(99999).expect("read");
|
||||
assert!(none.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sin_spill_read_spilled_es_none() {
|
||||
let mut s = Scrollback::new(20);
|
||||
for i in 0..50 {
|
||||
s.push_line(&format!("L{i:04}"));
|
||||
}
|
||||
// Hubo recorte pero no hay spill → no se puede recuperar.
|
||||
assert!(s.dropped() > 0);
|
||||
assert_eq!(s.spilled_count(), 0);
|
||||
assert!(s.read_spilled(0).expect("read").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_sobrevive_a_clones_del_scrollback() {
|
||||
// `Scrollback` es Clone (Pata clona el state del shell); el spill
|
||||
// se comparte por Arc, así las dos instancias appendean al MISMO
|
||||
// archivo. Acá comprobamos que el spilled_count se ve desde ambos.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("spill.log");
|
||||
let spill = SpillStore::create(&path).unwrap();
|
||||
let mut a = Scrollback::new(20);
|
||||
a.enable_spill(spill);
|
||||
for i in 0..30 {
|
||||
a.push_line(&format!("L{i:04}"));
|
||||
}
|
||||
let b = a.clone();
|
||||
// El clon ve el mismo spilled_count.
|
||||
assert_eq!(a.spilled_count(), b.spilled_count());
|
||||
// Y puede leer las mismas líneas.
|
||||
let from_a = a.read_spilled(2).unwrap().unwrap();
|
||||
let from_b = b.read_spilled(2).unwrap().unwrap();
|
||||
assert_eq!(from_a, from_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_almacena_utf8_intacto() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("spill.log");
|
||||
let spill = SpillStore::create(&path).unwrap();
|
||||
let mut s = Scrollback::new(15);
|
||||
s.enable_spill(spill);
|
||||
s.push_line("café ☕");
|
||||
s.push_line("niño ñ");
|
||||
s.push_line("hello world"); // empuja las anteriores a spill
|
||||
assert!(s.dropped() > 0);
|
||||
let cafe = s.read_spilled(0).unwrap().unwrap();
|
||||
assert_eq!(cafe, "café ☕");
|
||||
let nino = s.read_spilled(1).unwrap().unwrap();
|
||||
assert_eq!(nino, "niño ñ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_append_stays_under_cap_and_indexes_correctly() {
|
||||
// Muchas líneas, tope moderado: el store se mantiene acotado y el acceso
|
||||
// sigue correcto en todo el rango vigente.
|
||||
let mut s = Scrollback::new(4096);
|
||||
for i in 0..100_000 {
|
||||
s.push_line(&format!("fila numero {i}"));
|
||||
}
|
||||
assert!(s.byte_len() <= 4096);
|
||||
assert!(s.dropped() > 0);
|
||||
// Todas las líneas vigentes son accesibles y coherentes con su número.
|
||||
for idx in 0..s.len() {
|
||||
let n = s.line_number(idx); // 1-based global
|
||||
let expected = format!("fila numero {}", n - 1);
|
||||
assert_eq!(s.line(idx), Some(expected.as_str()));
|
||||
}
|
||||
// La última empujada fue la 100000ª.
|
||||
assert_eq!(s.total_pushed(), 100_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Tipos de la superficie + render virtualizado **modo línea** (Capas 1–2).
|
||||
//!
|
||||
//! Acá viven los tipos compartidos ([`TermMetrics`], [`TermPalette`],
|
||||
//! [`LineStyle`]) y la matemática pura de la ventana visible de **filas
|
||||
//! uniformes** ([`visible_window`]). El render modo línea, [`line_surface`], es
|
||||
//! el caso particular —un solo bloque de líneas que cubre todo el store— del
|
||||
//! modelo de bloques general de [`crate::blocks`] (Fase 2): delega en
|
||||
//! [`crate::blocks::block_surface`] para no duplicar la maquinaria de
|
||||
//! virtualización ni los builders de fila.
|
||||
//!
|
||||
//! La apuesta del SDD: scrollback **ilimitado** a costo de render
|
||||
//! **constante** — sólo se materializan las filas que caen en el viewport. El
|
||||
//! scroll vive en el **propio widget** (un `scroll_y` en px que el caller
|
||||
//! guarda en su Model), NO en un `transform` del panel sobre contenido alto
|
||||
//! (esa fue la fuente del bug clip+transform que ya costó — SDD §"Anti-features").
|
||||
//!
|
||||
//! El widget es agnóstico de shuma: el color/tinte de cada renglón los **inyecta
|
||||
//! el caller** vía `line_style` (Regla 2). La numeración del gutter es la global
|
||||
//! 1-based del store, estable aunque el frente se recorte.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::View;
|
||||
use llimphi_widget_scroll::max_offset;
|
||||
|
||||
use crate::blocks::{block_surface, Item};
|
||||
use crate::store::Scrollback;
|
||||
|
||||
/// Métricas de la superficie — todo derivado del `font_size`. Asume fuente
|
||||
/// monoespaciada (la mono embebida de `llimphi-text`): `char_width` es el
|
||||
/// avance fijo de un carácter, base para columnar y para ubicar la selección
|
||||
/// (Fase 3).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TermMetrics {
|
||||
pub font_size: f32,
|
||||
/// Alto de cada renglón, en px (`font_size * 1.4`).
|
||||
pub line_height: f32,
|
||||
/// Avance de un carácter mono, en px (`font_size * 0.6`).
|
||||
pub char_width: f32,
|
||||
}
|
||||
|
||||
impl Default for TermMetrics {
|
||||
fn default() -> Self {
|
||||
Self::for_font_size(13.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TermMetrics {
|
||||
pub const fn for_font_size(font_size: f32) -> Self {
|
||||
Self {
|
||||
font_size,
|
||||
line_height: font_size * 1.4,
|
||||
char_width: font_size * 0.6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paleta de la superficie. Defaults dark, derivables del [`Theme`].
|
||||
///
|
||||
/// [`Theme`]: llimphi_theme::Theme
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TermPalette {
|
||||
/// Fondo del área de texto.
|
||||
pub bg: Color,
|
||||
/// Fondo del gutter (columna de números).
|
||||
pub bg_gutter: Color,
|
||||
/// Color del texto por defecto (cuando `LineStyle::fg` no se pisa).
|
||||
pub fg_text: Color,
|
||||
/// Color de los números de línea del gutter.
|
||||
pub fg_line_number: Color,
|
||||
/// Track de la barra de scroll.
|
||||
pub bar_track: Color,
|
||||
/// Thumb de la barra en reposo.
|
||||
pub bar_thumb: Color,
|
||||
/// Thumb de la barra al pasar el cursor.
|
||||
pub bar_thumb_hover: Color,
|
||||
/// Fondo translúcido del highlight de selección (overlay por renglón
|
||||
/// sobre los rangos seleccionados). Caller elige el alpha; el widget lo
|
||||
/// pinta literal sobre el texto sin tintarlo.
|
||||
pub bg_selection: Color,
|
||||
}
|
||||
|
||||
impl Default for TermPalette {
|
||||
fn default() -> Self {
|
||||
Self::from_theme(&llimphi_theme::Theme::dark())
|
||||
}
|
||||
}
|
||||
|
||||
impl TermPalette {
|
||||
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
|
||||
// Selección: accent del tema con alpha bajo (~30%) — el texto se lee
|
||||
// y el rango queda evidente sin tapar el color del texto.
|
||||
let acc = t.accent.to_rgba8();
|
||||
let bg_selection = Color::from_rgba8(acc.r, acc.g, acc.b, 80);
|
||||
Self {
|
||||
bg: t.bg_input,
|
||||
bg_gutter: t.bg_panel,
|
||||
fg_text: t.fg_text,
|
||||
fg_line_number: t.fg_muted,
|
||||
bar_track: t.bg_panel_alt,
|
||||
bar_thumb: t.border,
|
||||
bar_thumb_hover: t.accent,
|
||||
bg_selection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Estilo de un renglón concreto, provisto por el caller. El widget no decide
|
||||
/// color: shuma tinta `ls`/paths/urls (vía `runs`), marca `stderr` con un
|
||||
/// `bg`, etc., sin que la superficie sepa de comandos.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LineStyle {
|
||||
/// Color base del texto del renglón. `None` → `palette.fg_text`.
|
||||
pub fg: Option<Color>,
|
||||
/// Overrides de color por rango de **bytes** del renglón
|
||||
/// (`(start, end, color)`) — coloreo semántico (ls, syntax). Se clampean
|
||||
/// al largo real del texto.
|
||||
pub runs: Vec<(usize, usize, Color)>,
|
||||
/// Tinte de fondo del renglón completo (p. ej. `stderr` en rojo tenue).
|
||||
/// El caller elige el alpha; el widget lo pinta literal.
|
||||
pub bg: Option<Color>,
|
||||
}
|
||||
|
||||
impl LineStyle {
|
||||
/// Renglón con un color base y sin runs ni tinte — el caso común.
|
||||
pub fn fg(color: Color) -> Self {
|
||||
Self {
|
||||
fg: Some(color),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// La ventana de filas visibles dado el scroll y el viewport. Resultado puro y
|
||||
/// testeable: el corazón de la virtualización de **filas uniformes**.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct VisibleWindow {
|
||||
/// Primera fila a materializar (índice 0-based **vigente** en el store).
|
||||
pub first: usize,
|
||||
/// Una más allá de la última fila a materializar (exclusivo).
|
||||
pub last: usize,
|
||||
/// Píxeles del scroll que caen **dentro** de la primera fila — el desfase
|
||||
/// con el que la columna de filas se sube para que el scroll sea suave por
|
||||
/// sub-renglón (no salta de fila en fila).
|
||||
pub partial_px: i64,
|
||||
}
|
||||
|
||||
impl VisibleWindow {
|
||||
/// Cantidad de filas que esta ventana materializa.
|
||||
pub fn count(&self) -> usize {
|
||||
self.last.saturating_sub(self.first)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alto total del contenido (px) si se pintara entero — `filas * alto_fila`.
|
||||
/// El scrollbar lo usa para dimensionar el thumb; nunca se materializa así.
|
||||
pub fn content_height(total_rows: usize, row_h: f32) -> f32 {
|
||||
total_rows as f32 * row_h
|
||||
}
|
||||
|
||||
/// El `scroll_y` que ancla el contenido **al fondo** (estilo terminal): el
|
||||
/// máximo offset posible. El caller lo fija mientras el usuario no scrollee
|
||||
/// arriba, así el append mantiene el fondo pegado.
|
||||
pub fn scroll_to_bottom(total_rows: usize, viewport_h: f32, row_h: f32) -> f32 {
|
||||
max_offset(content_height(total_rows, row_h), viewport_h)
|
||||
}
|
||||
|
||||
/// Calcula la ventana de filas a materializar para un stream **de filas
|
||||
/// uniformes** (sin bloques). **Puro** — sin GPU, sin Views.
|
||||
///
|
||||
/// `scroll_y` se clampea a `[0, max_offset]` acá mismo (defensa en
|
||||
/// profundidad). La ventana incluye una fila de guarda extra al fondo para
|
||||
/// cubrir el renglón parcialmente visible del borde inferior.
|
||||
pub fn visible_window(
|
||||
total_rows: usize,
|
||||
scroll_y: f32,
|
||||
viewport_h: f32,
|
||||
row_h: f32,
|
||||
) -> VisibleWindow {
|
||||
if total_rows == 0 || row_h <= 0.0 || viewport_h <= 0.0 {
|
||||
return VisibleWindow {
|
||||
first: 0,
|
||||
last: 0,
|
||||
partial_px: 0,
|
||||
};
|
||||
}
|
||||
let content_h = content_height(total_rows, row_h);
|
||||
let max_off = (content_h - viewport_h).max(0.0);
|
||||
let off = scroll_y.clamp(0.0, max_off);
|
||||
|
||||
let first = ((off / row_h).floor() as usize).min(total_rows.saturating_sub(1));
|
||||
// Desfase sub-renglón: cuánto del primer renglón ya pasó por arriba.
|
||||
let partial_px = (off - first as f32 * row_h).round() as i64;
|
||||
// Filas que entran en el viewport + el desfase + una de guarda al fondo.
|
||||
let rows_in_view = ((viewport_h + partial_px as f32) / row_h).ceil() as usize + 1;
|
||||
let last = (first + rows_in_view).min(total_rows);
|
||||
|
||||
VisibleWindow {
|
||||
first,
|
||||
last,
|
||||
partial_px,
|
||||
}
|
||||
}
|
||||
|
||||
/// Superficie de terminal **modo línea, virtualizada** — el caso de **un solo
|
||||
/// bloque** de líneas que cubre todo el store. Delega en
|
||||
/// [`crate::blocks::block_surface`].
|
||||
///
|
||||
/// `on_scroll(delta_px)` se invoca con el delta a sumar a `scroll_y` (rueda y
|
||||
/// arrastre de la barra); el caller acumula y clampea con
|
||||
/// [`llimphi_widget_scroll::clamp_offset`] en su `update`. `line_style(idx,
|
||||
/// texto)` da el color/tinte de cada renglón visible. `measure`, si se provee,
|
||||
/// recibe el alto real del viewport en cada paint (patrón de medición del shell).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn line_surface<Msg, S, F>(
|
||||
store: &Scrollback,
|
||||
scroll_y: f32,
|
||||
viewport_h: f32,
|
||||
metrics: TermMetrics,
|
||||
palette: &TermPalette,
|
||||
line_style: S,
|
||||
on_scroll: F,
|
||||
measure: Option<Arc<Mutex<f32>>>,
|
||||
) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + 'static,
|
||||
S: Fn(usize, &str) -> LineStyle,
|
||||
F: Fn(f32) -> Msg + Send + Sync + 'static,
|
||||
{
|
||||
block_surface(
|
||||
store,
|
||||
vec![Item::lines(0, store.len())],
|
||||
scroll_y,
|
||||
viewport_h,
|
||||
metrics,
|
||||
palette,
|
||||
line_style,
|
||||
on_scroll,
|
||||
measure,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ROW: f32 = 18.0;
|
||||
|
||||
#[test]
|
||||
fn empty_store_no_window() {
|
||||
let w = visible_window(0, 0.0, 600.0, ROW);
|
||||
assert_eq!(w.count(), 0);
|
||||
assert_eq!(w, VisibleWindow { first: 0, last: 0, partial_px: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_at_top_is_constant_cost() {
|
||||
// 1 M de filas, viewport de 600 px → ~34 filas + guarda, NO un millón.
|
||||
let total = 1_000_000;
|
||||
let w = visible_window(total, 0.0, 600.0, ROW);
|
||||
assert_eq!(w.first, 0);
|
||||
assert_eq!(w.partial_px, 0);
|
||||
// ceil(600/18)+1 = 34+1 = 35.
|
||||
assert_eq!(w.count(), 35);
|
||||
assert!(w.count() < 50, "el costo debe ser constante, no {total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_in_the_middle_has_partial_offset() {
|
||||
// Scroll a 1000 px: la primera fila visible es floor(1000/18)=55,
|
||||
// y el desfase sub-renglón es 1000 - 55*18 = 1000 - 990 = 10.
|
||||
let w = visible_window(1_000_000, 1000.0, 600.0, ROW);
|
||||
assert_eq!(w.first, 55);
|
||||
assert_eq!(w.partial_px, 10);
|
||||
assert!(w.count() < 50);
|
||||
assert!(w.last <= 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_clamps_to_bottom() {
|
||||
// Scroll exagerado → se clampa al máximo; la última fila visible es la
|
||||
// última del store, sin pasarse.
|
||||
let total = 500;
|
||||
let w = visible_window(total, 1e9, 600.0, ROW);
|
||||
assert_eq!(w.last, total);
|
||||
let bottom = scroll_to_bottom(total, 600.0, ROW);
|
||||
let w2 = visible_window(total, bottom, 600.0, ROW);
|
||||
assert_eq!(w2.last, total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_smaller_than_viewport_shows_all() {
|
||||
// 10 filas en 600 px de viewport: entran todas, sin scroll.
|
||||
let w = visible_window(10, 0.0, 600.0, ROW);
|
||||
assert_eq!(w.first, 0);
|
||||
assert_eq!(w.last, 10);
|
||||
assert_eq!(scroll_to_bottom(10, 600.0, ROW), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_count_independent_of_scrollback_size() {
|
||||
// La invariante central del SDD: el costo no depende del total.
|
||||
let a = visible_window(1_000, 9000.0, 600.0, ROW).count();
|
||||
let b = visible_window(10_000_000, 9000.0, 600.0, ROW).count();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Smoke test del `CellPipeline` (Fase 4.2 del SDD-TERMINAL).
|
||||
//!
|
||||
//! No verifica píxeles — eso requiere conocer la fuente exacta, font hinting
|
||||
//! del rasterizer y un pipeline de comparación. Sí verifica:
|
||||
//!
|
||||
//! - `CellPipeline::new` compila el shader WGSL sin errores de naga.
|
||||
//! - `create_atlas_texture` sube bytes a una `R8Unorm` sin pánico.
|
||||
//! - `draw` ejecuta sin errores wgpu con un atlas vivo y N instancias —
|
||||
//! por debajo y por arriba del cap del adapter, sin reasignar buffers.
|
||||
//!
|
||||
//! Corre en cualquier adapter wgpu disponible (en CI sin GPU = llvmpipe).
|
||||
|
||||
use llimphi_hal::{wgpu, Hal};
|
||||
use llimphi_widget_terminal::cell_pipeline::{
|
||||
pack_rgba, CellInstance, CellPipeline, CellUniforms,
|
||||
};
|
||||
use llimphi_widget_terminal::glyph_atlas::GlyphAtlas;
|
||||
|
||||
const W: u32 = 256;
|
||||
const H: u32 = 256;
|
||||
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("cell-smoke-target"),
|
||||
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::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
(tex, view)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_compila_y_dibuja_sin_panico() {
|
||||
let hal = pollster::block_on(Hal::new(None)).expect("hal");
|
||||
let pipeline = CellPipeline::new(&hal.device, FMT);
|
||||
let (_tex, view) = make_target(&hal.device);
|
||||
|
||||
// Atlas con un par de glifos.
|
||||
let mut atlas = GlyphAtlas::new(
|
||||
llimphi_ui::llimphi_text::MONO_FONT_BYTES,
|
||||
14.0,
|
||||
16,
|
||||
4,
|
||||
)
|
||||
.expect("atlas");
|
||||
let slot_a = atlas.glyph_for('A').unwrap();
|
||||
let slot_b = atlas.glyph_for('B').unwrap();
|
||||
let (atlas_w, atlas_h) = atlas.size();
|
||||
let (cell_w, cell_h) = atlas.cell_size();
|
||||
|
||||
let (_atlas_tex, atlas_view) =
|
||||
CellPipeline::create_atlas_texture(&hal.device, &hal.queue, atlas.pixels(), atlas.size());
|
||||
|
||||
// Dos celdas, A y B en (0,0) y (cell_w,0).
|
||||
let cells = vec![
|
||||
CellInstance {
|
||||
cell_x: 0.0,
|
||||
cell_y: 0.0,
|
||||
uv_x: slot_a.px as f32,
|
||||
uv_y: slot_a.py as f32,
|
||||
uv_w: cell_w as f32,
|
||||
uv_h: cell_h as f32,
|
||||
fg_rgba: pack_rgba(255, 255, 255, 255),
|
||||
bg_rgba: pack_rgba(20, 20, 20, 255),
|
||||
},
|
||||
CellInstance {
|
||||
cell_x: cell_w as f32,
|
||||
cell_y: 0.0,
|
||||
uv_x: slot_b.px as f32,
|
||||
uv_y: slot_b.py as f32,
|
||||
uv_w: cell_w as f32,
|
||||
uv_h: cell_h as f32,
|
||||
fg_rgba: pack_rgba(100, 255, 100, 255),
|
||||
bg_rgba: pack_rgba(0, 0, 0, 255),
|
||||
},
|
||||
];
|
||||
|
||||
let uniforms = CellUniforms {
|
||||
viewport_w: W as f32,
|
||||
viewport_h: H as f32,
|
||||
cell_w: cell_w as f32,
|
||||
cell_h: cell_h as f32,
|
||||
atlas_w: atlas_w as f32,
|
||||
atlas_h: atlas_h as f32,
|
||||
_pad0: 0.0,
|
||||
_pad1: 0.0,
|
||||
};
|
||||
|
||||
let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("cell-smoke-encoder"),
|
||||
});
|
||||
|
||||
// Clear primero para tener un load:Load coherente.
|
||||
{
|
||||
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("cell-smoke-clear"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
}
|
||||
|
||||
pipeline.draw(
|
||||
&hal.device,
|
||||
&hal.queue,
|
||||
&mut encoder,
|
||||
&view,
|
||||
&atlas_view,
|
||||
&cells,
|
||||
uniforms,
|
||||
);
|
||||
|
||||
hal.queue.submit(std::iter::once(encoder.finish()));
|
||||
hal.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_con_cero_instancias_es_no_op() {
|
||||
let hal = pollster::block_on(Hal::new(None)).expect("hal");
|
||||
let pipeline = CellPipeline::new(&hal.device, FMT);
|
||||
let (_tex, view) = make_target(&hal.device);
|
||||
|
||||
let mut atlas = GlyphAtlas::new(
|
||||
llimphi_ui::llimphi_text::MONO_FONT_BYTES,
|
||||
14.0,
|
||||
16,
|
||||
4,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = atlas.glyph_for('A'); // tener algo en el atlas
|
||||
let (_atlas_tex, atlas_view) =
|
||||
CellPipeline::create_atlas_texture(&hal.device, &hal.queue, atlas.pixels(), atlas.size());
|
||||
|
||||
let (atlas_w, atlas_h) = atlas.size();
|
||||
let (cell_w, cell_h) = atlas.cell_size();
|
||||
let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("cell-smoke-empty-encoder"),
|
||||
});
|
||||
pipeline.draw(
|
||||
&hal.device,
|
||||
&hal.queue,
|
||||
&mut encoder,
|
||||
&view,
|
||||
&atlas_view,
|
||||
&[],
|
||||
CellUniforms {
|
||||
viewport_w: W as f32,
|
||||
viewport_h: H as f32,
|
||||
cell_w: cell_w as f32,
|
||||
cell_h: cell_h as f32,
|
||||
atlas_w: atlas_w as f32,
|
||||
atlas_h: atlas_h as f32,
|
||||
_pad0: 0.0,
|
||||
_pad1: 0.0,
|
||||
},
|
||||
);
|
||||
hal.queue.submit(std::iter::once(encoder.finish()));
|
||||
hal.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
}
|
||||
Reference in New Issue
Block a user