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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+32
View File
@@ -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"
+433
View File
@@ -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();
}
+235
View File
@@ -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();
}
+790
View File
@@ -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 12).
///
/// 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);
}
}
+574
View File
@@ -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");
}
}
+208
View File
@@ -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);
}
}
+405
View File
@@ -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");
}
}
+51
View File
@@ -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,
};
+694
View File
@@ -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");
}
}
+619
View File
@@ -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);
}
}
+308
View File
@@ -0,0 +1,308 @@
//! Tipos de la superficie + render virtualizado **modo línea** (Capas 12).
//!
//! 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());
}