From 61a444795b9e1961d05ec90dd84b489a66df2256 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 8 Oct 2026 17:44:35 +0100 Subject: [PATCH] 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. --- rust/src/lib.rs | 1 + rust/src/tui/actions.rs | 268 ++++++++++++++++++ rust/src/tui/bench.rs | 126 +++++++++ rust/src/tui/canvas.rs | 360 ++++++++++++++++++++++++ rust/src/tui/color256.rs | 92 +++++++ rust/src/tui/data.rs | 181 +++++++++++++ rust/src/tui/infoview.rs | 160 +++++++++++ rust/src/tui/keys.rs | 228 ++++++++++++++++ rust/src/tui/mapview.rs | 383 ++++++++++++++++++++++++++ rust/src/tui/mod.rs | 88 ++++++ rust/src/tui/modal.rs | 229 ++++++++++++++++ rust/src/tui/modals.rs | 438 ++++++++++++++++++++++++++++++ rust/src/tui/model.rs | 572 +++++++++++++++++++++++++++++++++++++++ rust/src/tui/mouse.rs | 121 +++++++++ rust/src/tui/render.rs | 378 ++++++++++++++++++++++++++ rust/src/tui/run.rs | 474 ++++++++++++++++++++++++++++++++ rust/src/tui/search.rs | 277 +++++++++++++++++++ rust/src/tui/side.rs | 111 ++++++++ rust/src/tui/table.rs | 190 +++++++++++++ rust/src/tui/tests.rs | 513 +++++++++++++++++++++++++++++++++++ rust/src/tui/theme.rs | 193 +++++++++++++ rust/src/tui/views.rs | 535 ++++++++++++++++++++++++++++++++++++ 22 files changed, 5918 insertions(+) create mode 100644 rust/src/tui/actions.rs create mode 100644 rust/src/tui/bench.rs create mode 100644 rust/src/tui/canvas.rs create mode 100644 rust/src/tui/color256.rs create mode 100644 rust/src/tui/data.rs create mode 100644 rust/src/tui/infoview.rs create mode 100644 rust/src/tui/keys.rs create mode 100644 rust/src/tui/mapview.rs create mode 100644 rust/src/tui/mod.rs create mode 100644 rust/src/tui/modal.rs create mode 100644 rust/src/tui/modals.rs create mode 100644 rust/src/tui/model.rs create mode 100644 rust/src/tui/mouse.rs create mode 100644 rust/src/tui/render.rs create mode 100644 rust/src/tui/run.rs create mode 100644 rust/src/tui/search.rs create mode 100644 rust/src/tui/side.rs create mode 100644 rust/src/tui/table.rs create mode 100644 rust/src/tui/tests.rs create mode 100644 rust/src/tui/theme.rs create mode 100644 rust/src/tui/views.rs diff --git a/rust/src/lib.rs b/rust/src/lib.rs index ae46da4..d084006 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -12,6 +12,7 @@ pub mod scan; pub mod snapshot; pub mod textutil; pub mod treemap; +pub mod tui; /// Boxed error used across mode boundaries. pub type Error = Box; diff --git a/rust/src/tui/actions.rs b/rust/src/tui/actions.rs new file mode 100644 index 0000000..3ca119e --- /dev/null +++ b/rust/src/tui/actions.rs @@ -0,0 +1,268 @@ +//! Mutating and long-running actions: trash/delete and duplicate search. + +use super::canvas::{Canvas, Style, BOLD}; +use super::modals::{new_info_modal, ConfirmModal}; +use super::model::{Cmd, Model, Msg}; +use super::san; +use super::table::{col, Table, TableRow}; +use crate::cancel::Cancel; +use crate::duplicate::{self, Finder}; +use crate::inventory::{Delta, NodeId, Tree, FLAG_DELETED, NO_NODE}; +use crate::platform::{self, Expect}; +use crate::textutil; +use crate::treemap::Rect; +use std::sync::{Arc, RwLock}; + +// ---- Trash --------------------------------------------------------------- + +impl Model { + /// Asks for confirmation before moving the selection to the trash. On a + /// headless system (no desktop, so nobody empties the trash and the space + /// is never freed) it deletes permanently instead, after a confirmation + /// that says so. + pub fn request_trash(&mut self, tr: &Tree) -> Cmd { + if self.opts.read_only { + return self.warn("Read-only mode: modifying actions are disabled"); + } + if self.snapshot { + return self.warn("Snapshot view: nothing to delete on the live filesystem"); + } + if self.scanning { + return self.warn("Wait for the scan to finish before deleting items"); + } + let id = self.selected_node(tr); + if id == NO_NODE || id == 0 { + return Cmd::None; + } + let n = tr.node(id); + let path = tr.path(id); + let want = Expect { uid: n.uid, mode: n.mode }; + let what = if n.is_dir() { format!("directory with {} files", textutil::count(n.files as i64)) } else { "file".into() }; + let mut body = vec![san(&tr.path_bytes(id)), String::new(), format!("{what} · {}", textutil::size(n.total(self.size_mode))), String::new()]; + if platform::headless() { + body.push("This is a headless system (no X or Wayland display), so there is no trash:".into()); + body.push("it will be permanently deleted and cannot be recovered.".into()); + self.open_modal(Box::new(ConfirmModal { + ttl: "Delete permanently? Are you sure?".into(), + body, + yes: "delete permanently".into(), + on_yes: Some(Box::new(move |m: &mut Model| { + let t = m.tree.clone(); + Cmd::run(move || { + let err = platform::delete(&path, want).err().map(|e| e.to_string()); + Msg::TrashDone { tree: t, id, err, done: "Deleted".into() } + }) + })), + })); + return Cmd::None; + } + body.push(format!("It will be moved to the {}, not permanently deleted.", platform::TRASH_NAME)); + self.open_modal(Box::new(ConfirmModal { + ttl: "Move to trash?".into(), + body, + yes: format!("move to {}", platform::TRASH_NAME), + on_yes: Some(Box::new(move |m: &mut Model| { + let t = m.tree.clone(); + Cmd::run(move || { + let err = platform::trash(&path, want).err().map(|e| e.to_string()); + Msg::TrashDone { tree: t, id, err, done: format!("Moved to {}", platform::TRASH_NAME) } + }) + })), + })); + Cmd::None + } + + pub fn trash_done(&mut self, tree: Arc>, id: NodeId, err: Option, done: String) -> Cmd { + if let Some(e) = err { + return self.warn(format!("Delete failed: {}", textutil::sanitize(&e))); + } + if !Arc::ptr_eq(&tree, &self.tree) || self.scanning { + // A rescan replaced the inventory; the new scan reflects the change. + return self.info(done); + } + // The scan has finished, so the UI thread is the tree's only writer. + let name = { + let mut t = self.tree.write().unwrap(); + let n = t.node(id).clone(); + let mut d = Delta { size: -n.tot_size, alloc: -n.tot_alloc, files: -(n.files as i64), dirs: -(n.dirs as i64), ..Default::default() }; + if n.is_dir() { + d.dirs -= 1; + if let Some(cs) = t.cat_sizes(id) { + for (c, v) in d.cat.iter_mut().zip(cs) { + *c = -v; + } + } + } else { + d.files -= 1; + d.cat[n.cat as usize] = -n.tot_alloc; + } + t.propagate(n.parent, &d); + let nm = t.node_mut(id); + nm.flags |= FLAG_DELETED; + (nm.tot_size, nm.tot_alloc) = (0, 0); + n.name + }; + self.invalidate(); + self.info(format!("{done}: {}", san(&name))) + } + + // ---- Duplicates ---------------------------------------------------------- + + pub fn start_duplicates(&mut self) -> Cmd { + if self.snapshot { + return self.warn("Duplicate detection needs the live filesystem"); + } + if self.scanning { + return self.warn("Wait for the scan to finish first"); + } + if self.dups.is_some() { + self.ensure_dup_view(); + return Cmd::None; + } + self.open_modal(Box::new(ConfirmModal { + ttl: "Find duplicate files?".into(), + body: vec![ + "Files are grouped by size, then sampled, then fully hashed with SHA-256.".into(), + "Only identical hashes are reported as duplicates.".into(), + String::new(), + "This reads file contents (it may take a while and can update access times).".into(), + ], + yes: "start".into(), + on_yes: Some(Box::new(|m: &mut Model| { + let cancel = Cancel::new(); + let f = Arc::new(Finder::default()); + m.dups = Some(DupState { running: true, finder: f.clone(), groups: Vec::new(), err: None, cancel: cancel.clone() }); + m.ensure_dup_view(); + let t = m.tree.clone(); + let tick = m.start_ticking(); + Cmd::batch(vec![ + tick, + Cmd::run(move || { + let g = f.find(&cancel, &t, duplicate::Options { min_size: 1, workers: 4 }); + Msg::DupDone { finder: f, groups: g } + }), + ]) + })), + })); + Cmd::None + } + + pub fn ensure_dup_view(&mut self) { + if let Some(i) = self.views.iter().position(|v| matches!(v, super::views::View::Dups(_))) { + self.set_view(i); + return; + } + self.views.push(super::views::View::Dups(DupView::default())); + self.set_view(self.views.len() - 1); + } + + pub fn dup_done(&mut self, finder: Arc, groups: Option>) -> Cmd { + let Some(d) = self.dups.as_mut().filter(|d| Arc::ptr_eq(&d.finder, &finder)) else { return Cmd::None }; + d.running = false; + d.err = groups.is_none().then(|| "cancelled".to_string()); + d.groups = groups.unwrap_or_default(); + let (n, wasted) = (d.groups.len(), d.groups.iter().map(|g| g.wasted()).sum::()); + self.dirty = true; + self.info(format!("Duplicates: {n} verified groups, {} reclaimable", textutil::size(wasted))) + } +} + +pub(crate) struct DupState { + pub running: bool, + pub finder: Arc, + pub groups: Vec, + pub err: Option, + pub cancel: Cancel, +} + +#[derive(Default)] +pub(crate) struct DupView { + pub tb: Table, + pub rows: Vec<(usize, NodeId)>, // (group, file); NO_NODE for a group header +} + +impl DupView { + pub fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) { + let t = m.theme.clone(); + let Some(d) = &m.dups else { + cv.text_centered(r.x, r.y + r.h / 2, r.w, "Press D to search for duplicates", t.muted()); + return; + }; + if d.running { + let p = d.finder.progress(); + cv.fill(r, " ", t.base()); + let y = r.y + r.h / 2 - 2; + cv.text_centered(r.x, y, r.w, &format!("Finding duplicates — {}…", p.stage), Style::new(t.accent, t.bg).with(BOLD)); + cv.text_centered(r.x, y + 2, r.w, + &format!("{} candidate files in {} size groups", textutil::count(p.candidates), textutil::count(p.candidate_groups)), t.base()); + if p.bytes_to_hash > 0 { + let frac = p.bytes_hashed as f64 / p.bytes_to_hash as f64; + let bw = 60.min(r.w - 10); + m.paint_bar(cv, r.x + (r.w - bw) / 2, y + 4, bw, frac, t.accent, false); + cv.text_centered(r.x, y + 5, r.w, + &format!("{} of {} hashed · {} skipped", textutil::size(p.bytes_hashed), textutil::size(p.bytes_to_hash), textutil::count(p.skipped)), + t.muted()); + } + return; + } + self.rows.clear(); + let mut rows = Vec::new(); + for (gi, g) in d.groups.iter().enumerate() { + self.rows.push((gi, NO_NODE)); + let hash: String = g.hash[..8].iter().map(|b| format!("{b:02x}")).collect(); + rows.push(TableRow { + cells: vec![textutil::size(g.wasted()), format!("{} × {}", g.files.len(), textutil::size(g.size)), + format!("verified identical · sha256 {hash}…")], + styles: vec![Some(Style::new(t.warn, t.bg).with(BOLD)), Some(Style::new(t.fg, t.bg).with(BOLD)), Some(Style::new(t.ok, t.bg))], + bar: -1.0, + ..Default::default() + }); + for &id in &g.files { + self.rows.push((gi, id)); + rows.push(TableRow { cells: vec![String::new(), String::new(), format!(" {}", san(&tr.path_bytes(id)))], bar: -1.0, ..Default::default() }); + } + } + let mut title = format!("Duplicates — {} groups (Enter details · Space go to)", d.groups.len()); + if let Some(e) = &d.err { + title += &format!(" — stopped: {}", textutil::sanitize(e)); + } + let cols = [col("WASTED", 10, true, false), col("COPIES", 16, false, false), col("", 0, false, false)]; + self.tb.paint(m, cv, r, &cols, &rows, &title); + } + + pub fn key(&mut self, m: &mut Model, tr: &Tree, k: &str) -> (bool, Cmd) { + if self.tb.key(k, self.rows.len()) { + return (true, Cmd::None); + } + if self.tb.at() < self.rows.len() { + let (g, mut id) = self.rows[self.tb.at()]; + if id == NO_NODE { + if let Some(d) = m.dups.as_ref().filter(|d| g < d.groups.len()) { + id = d.groups[g].files[0]; + } + } + if id != NO_NODE { + match k { + "enter" => { + let md = new_info_modal(m, tr, id); + m.open_modal(md); + return (true, Cmd::None); + } + "space" => { + m.jump_to(tr, id); + return (true, Cmd::None); + } + "c" | "o" | "d" => m.sel = id as i64, + _ => {} + } + } + } + if k == "esc" { + if let Some(d) = m.dups.as_ref().filter(|d| d.running) { + d.cancel.cancel(); + return (true, Cmd::None); + } + } + (false, Cmd::None) + } +} diff --git a/rust/src/tui/bench.rs b/rust/src/tui/bench.rs new file mode 100644 index 0000000..35a0d3f --- /dev/null +++ b/rust/src/tui/bench.rs @@ -0,0 +1,126 @@ +//! Benchmark hooks: the model driven with no terminal, rendering in +//! true colour, as the Go benchmarks do. + +use super::model::{Key, Model, Msg, Options, Profile}; +use crate::cancel::Cancel; +use crate::config::Settings; +use crate::inventory::{Category, Kind, NodeId, SizeMode, Tree}; +use std::sync::{Arc, RwLock}; + +/// A model with no terminal. Every method that renders returns the frame +/// length so the work cannot be optimised away. +pub struct BenchModel { + m: Model, +} + +pub(crate) fn opts_for(tree: Arc>) -> Options { + Options { + read_only: false, + theme: String::new(), + size_mode: SizeMode::Allocated, + mouse: false, + color: String::new(), + save: String::new(), + settings: Settings::default(), + start: Arc::new(move |_| Ok((None, tree.clone()))), + diff: None, + } +} + +impl BenchModel { + pub fn new(tree: Arc>, w: u16, h: u16) -> BenchModel { + let mut m = Model::new(opts_for(tree.clone()), None, tree, Cancel::new()); + m.profile = Profile::TrueColor; + m.update(Msg::Size(w as i32, h as i32)); + BenchModel { m } + } + + /// Invalidates the data caches and renders: sorting children, LOD + /// selection, layout and paint. + pub fn cold_frame(&mut self) -> usize { + self.m.invalidate(); + self.m.view().len() + } + + /// Resizes and renders (child lists stay cached). + pub fn resize(&mut self, w: u16, h: u16) -> usize { + self.m.update(Msg::Size(w as i32, h as i32)); + self.m.view().len() + } + + /// Sends a key ("right", "down", "left", "up", …) and renders. + pub fn key(&mut self, k: &str) -> usize { + self.m.update(Msg::Key(Key::from_name(k))); + self.m.view().len() + } + + /// Makes the root's child with this name the zoom root. + pub fn zoom_to_child_named(&mut self, name: &[u8]) { + let t = self.m.tree.clone(); + let g = t.read().unwrap(); + if let Some((id, _)) = g.children(0).find(|(_, n)| &*n.name == name) { + self.m.zoom = id; + } + } + + /// Sets the query and runs the filter synchronously. + pub fn apply_filter(&mut self, q: &str) { + self.m.search.ed.set(q); + let _ = self.m.query_changed(); + for msg in self.m.apply_filter().run_sync() { + self.m.update(msg); + } + } +} + +/// A small deterministic PRNG (splitmix64). +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } + /// Uniform in (0, 1]. + fn unit(&mut self) -> f64 { + ((self.next() >> 11) + 1) as f64 / (1u64 << 53) as f64 + } + fn exp(&mut self) -> f64 { + -self.unit().ln() + } + fn intn(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +/// ~1M nodes in memory: 200 top-level dirs with 40 subdirectories of 100 +/// files each, plus one flat directory with 200k files. +pub fn big_tree() -> Tree { + let mut r = Rng(1); + let mut t = Tree::new(b"/big", Kind::Dir); + let mut file = |t: &mut Tree, p: NodeId, i: usize| { + let id = t.add(p, format!("f{i}.dat").as_bytes(), Kind::File); + let n = t.node_mut(id); + n.size = (r.exp() * 1e6) as i64; + n.alloc = n.size; + n.cat = Category::from_u8(r.intn(crate::inventory::NUM_CATEGORIES as u64) as u8); + }; + let flat = t.add(0, b"flat", Kind::Dir); + for i in 0..200_000 { + file(&mut t, flat, i); + } + for d in 0..200 { + let top = t.add(0, format!("dir{d}").as_bytes(), Kind::Dir); + for s in 0..40 { + let sub = t.add(top, format!("sub{s}").as_bytes(), Kind::Dir); + for f in 0..100 { + file(&mut t, sub, f); + } + } + } + t.recompute(); + t +} diff --git a/rust/src/tui/canvas.rs b/rust/src/tui/canvas.rs new file mode 100644 index 0000000..a8a04b0 --- /dev/null +++ b/rust/src/tui/canvas.rs @@ -0,0 +1,360 @@ +//! An in-memory cell grid that every view paints into; serialised once per +//! frame with minimal SGR changes. + +use super::color256::{quant16, quant256}; +use crate::textutil; +use crate::treemap::Rect; +use std::fmt::Write; + +/// 0 for the terminal default, otherwise 0x01RRGGBB. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)] +pub(crate) struct Color(pub u32); + +/// Builds a colour. +pub(crate) const fn rgb(r: u8, g: u8, b: u8) -> Color { + Color(1 << 24 | (r as u32) << 16 | (g as u32) << 8 | b as u32) +} + +impl Color { + /// Parses "#rrggbb". + pub fn hex(s: &str) -> Option { + let s = s.strip_prefix('#').unwrap_or(s); + if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + u32::from_str_radix(s, 16).ok().map(|v| Color(1 << 24 | v)) + } + + pub fn is_default(self) -> bool { + self.0 == 0 + } + + pub fn r(self) -> u8 { + (self.0 >> 16) as u8 + } + pub fn g(self) -> u8 { + (self.0 >> 8) as u8 + } + pub fn b(self) -> u8 { + self.0 as u8 + } + + pub fn comps(self) -> (f64, f64, f64) { + (self.r() as f64 / 255.0, self.g() as f64 / 255.0, self.b() as f64 / 255.0) + } + + /// Blends c towards o by t (0..1). Default colours are returned as-is. + pub fn mix(self, o: Color, t: f64) -> Color { + if self.0 == 0 || o.0 == 0 { + return self; + } + let (r1, g1, b1) = self.comps(); + let (r2, g2, b2) = o.comps(); + let f = |a: f64, b: f64| ((a + (b - a) * t) * 255.0) as u8; + rgb(f(r1, r2), f(g1, g2), f(b1, b2)) + } + + /// Perceived brightness 0..1. + pub fn luma(self) -> f64 { + let (r, g, b) = self.comps(); + 0.299 * r + 0.587 * g + 0.114 * b + } +} + +/// Text attribute bit set. +pub(crate) type Attr = u8; +pub(crate) const BOLD: Attr = 1; +pub(crate) const FAINT: Attr = 2; +pub(crate) const ITALIC: Attr = 4; +pub(crate) const UNDERLINE: Attr = 8; +pub(crate) const REVERSE: Attr = 16; + +/// A cell style. +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] +pub(crate) struct Style { + pub fg: Color, + pub bg: Color, + pub attr: Attr, +} + +impl Style { + pub const fn new(fg: Color, bg: Color) -> Style { + Style { fg, bg, attr: 0 } + } + pub const fn with(self, a: Attr) -> Style { + Style { attr: self.attr | a, ..self } + } +} + +const INLINE: usize = 15; +const HEAP: u8 = 255; + +/// One grid cell. The cluster is stored inline (n bytes) or, if longer, +/// in the canvas side table (n == HEAP, index in the first four bytes). +/// n == 0 marks the continuation of a wide cluster. +#[derive(Clone, Copy)] +struct Cell { + g: [u8; INLINE], + n: u8, + w: u8, + st: Style, +} + +impl Cell { + fn space(st: Style) -> Cell { + let mut g = [0; INLINE]; + g[0] = b' '; + Cell { g, n: 1, w: 1, st } + } + fn is_cont(&self) -> bool { + self.n == 0 + } + fn set_space(&mut self) { + self.g[0] = b' '; + self.n = 1; + self.w = 1; + } +} + +/// Box glyph sets. +#[derive(Clone, Copy)] +pub(crate) struct BoxChars { + pub h: &'static str, + pub v: &'static str, + pub tl: &'static str, + pub tr: &'static str, + pub bl: &'static str, + pub br: &'static str, +} + +pub(crate) const BOX_LIGHT: BoxChars = BoxChars { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘" }; +pub(crate) const BOX_ROUND: BoxChars = BoxChars { h: "─", v: "│", tl: "╭", tr: "╮", bl: "╰", br: "╯" }; +pub(crate) const BOX_DOUBLE: BoxChars = BoxChars { h: "═", v: "║", tl: "╔", tr: "╗", bl: "╚", br: "╝" }; +pub(crate) const BOX_ASCII: BoxChars = BoxChars { h: "-", v: "|", tl: "+", tr: "+", bl: "+", br: "+" }; +pub(crate) const BOX_ASCII_SEL: BoxChars = BoxChars { h: "=", v: "#", tl: "#", tr: "#", bl: "#", br: "#" }; + +/// Selects how colours are serialised. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Depth { + True, + C256, + C16, + None, // attributes only +} + +/// An in-memory cell grid. +pub(crate) struct Canvas { + pub w: i32, + pub h: i32, + c: Vec, + big: Vec, +} + +impl Canvas { + /// Creates a canvas filled with spaces in style st. + pub fn new(w: i32, h: i32, st: Style) -> Canvas { + let (w, h) = (w.max(0), h.max(0)); + Canvas { w, h, c: vec![Cell::space(st); (w * h) as usize], big: Vec::new() } + } + + fn inside(&self, x: i32, y: i32) -> bool { + x >= 0 && y >= 0 && x < self.w && y < self.h + } + + fn cell_str<'a>(&'a self, c: &'a Cell) -> &'a str { + if c.n == HEAP { + let i = u32::from_le_bytes([c.g[0], c.g[1], c.g[2], c.g[3]]) as usize; + return &self.big[i]; + } + // Only whole UTF-8 clusters are ever stored. + std::str::from_utf8(&c.g[..c.n as usize]).unwrap_or("?") + } + + fn make(&mut self, s: &str, w: i32, st: Style) -> Cell { + let mut g = [0; INLINE]; + let n = if s.len() <= INLINE { + g[..s.len()].copy_from_slice(s.as_bytes()); + s.len() as u8 + } else { + g[..4].copy_from_slice(&(self.big.len() as u32).to_le_bytes()); + self.big.push(s.to_string()); + HEAP + }; + Cell { g, n, w: w as u8, st } + } + + /// Writes one cluster of width w at (x, y), repairing any wide cluster + /// it overlaps so the grid never holds half a character. + fn put(&mut self, x: i32, y: i32, s: &str, w: i32, st: Style) { + if !self.inside(x, y) || x + w > self.w { + return; + } + let row = (y * self.w) as usize; + let (xu, wu, cw) = (x as usize, w as usize, self.w as usize); + for i in xu..xu + wu { + if self.c[row + i].is_cont() && i > 0 && i == xu { + // overwriting the tail of a wide cluster + self.c[row + i - 1].set_space(); + } + let c = self.c[row + i]; + if !c.is_cont() && c.w > 1 && i + 1 < cw && self.c[row + i + 1].is_cont() { + // overwriting its head + self.c[row + i + 1].set_space(); + } + } + self.c[row + xu] = self.make(s, w, st); + for i in 1..wu { + self.c[row + xu + i] = Cell { g: [0; INLINE], n: 0, w: 0, st }; + } + } + + /// Draws s at (x, y), clipped to max_w cells and the canvas. Control + /// characters are replaced as a second line of defence (callers sanitise + /// untrusted strings first). It returns the number of cells used. + pub fn text(&mut self, x: i32, y: i32, s: &str, max_w: i32, st: Style) -> i32 { + if y < 0 || y >= self.h { + return 0; + } + let limit = (x + max_w).min(self.w); + let mut cx = x; + textutil::clusters(s, |c, w| { + let (mut c, mut w) = (c, w as i32); + let b = c.as_bytes(); + if b[0] < 0x20 || b[0] == 0x7f || (b.len() > 1 && b[0] == 0xc2 && b[1] < 0xa0) { + (c, w) = ("?", 1); + } + if cx + w > limit { + // A wide cluster that does not fit leaves a blank cell. + while cx < limit { + if cx >= 0 { + self.put(cx, y, " ", 1, st); + } + cx += 1; + } + return false; + } + if cx >= 0 { + self.put(cx, y, c, w, st); + } + cx += w; + true + }); + cx - x + } + + /// Draws s centred within [x, x+w). + pub fn text_centered(&mut self, x: i32, y: i32, w: i32, s: &str, st: Style) { + let s = textutil::truncate(s, w.max(0) as usize); + self.text(x + (w - textutil::width(&s) as i32) / 2, y, &s, w, st); + } + + /// Draws s right-aligned ending at x+w. + pub fn text_right(&mut self, x: i32, y: i32, w: i32, s: &str, st: Style) { + let s = textutil::truncate(s, w.max(0) as usize); + self.text(x + w - textutil::width(&s) as i32, y, &s, w, st); + } + + /// Fills r with the single-cell cluster ch. + pub fn fill(&mut self, r: Rect, ch: &str, st: Style) { + for y in r.y.max(0)..(r.y + r.h).min(self.h) { + for x in r.x.max(0)..(r.x + r.w).min(self.w) { + self.put(x, y, ch, 1, st); + } + } + } + + /// Applies f to the style of every cell in r. + pub fn restyle(&mut self, r: Rect, f: impl Fn(Style) -> Style) { + for y in r.y.max(0)..(r.y + r.h).min(self.h) { + for x in r.x.max(0)..(r.x + r.w).min(self.w) { + let c = &mut self.c[(y * self.w + x) as usize]; + c.st = f(c.st); + } + } + } + + /// Draws a border around r (which must be at least 2×2). + pub fn draw_box(&mut self, r: Rect, b: BoxChars, st: Style) { + if r.w < 2 || r.h < 2 { + return; + } + let (x1, y1) = (r.x + r.w - 1, r.y + r.h - 1); + for x in r.x + 1..x1 { + self.put(x, r.y, b.h, 1, st); + self.put(x, y1, b.h, 1, st); + } + for y in r.y + 1..y1 { + self.put(r.x, y, b.v, 1, st); + self.put(x1, y, b.v, 1, st); + } + self.put(r.x, r.y, b.tl, 1, st); + self.put(x1, r.y, b.tr, 1, st); + self.put(r.x, y1, b.bl, 1, st); + self.put(x1, y1, b.br, 1, st); + } + + /// Draws a horizontal rule. + pub fn hline(&mut self, x: i32, y: i32, w: i32, ch: &str, st: Style) { + for i in x..x + w { + self.put(i, y, ch, 1, st); + } + } + + /// Serialises the canvas as lines of text with SGR styling. For 256 and + /// 16 colours it quantizes with its own perceptual mapping (see + /// color256.rs); Depth::None keeps attributes only. + pub fn render(&self, depth: Depth) -> String { + let mut b = String::with_capacity((self.w * self.h * 4) as usize); + for y in 0..self.h { + if y > 0 { + b.push('\n'); + } + let mut cur = Style::default(); + let mut first = true; + for x in 0..self.w { + let c = &self.c[(y * self.w + x) as usize]; + if c.is_cont() { + continue; + } + if first || c.st != cur { + sgr(&mut b, c.st, depth); + cur = c.st; + first = false; + } + b.push_str(self.cell_str(c)); + } + b.push_str("\x1b[0m"); + } + b + } +} + +fn sgr(b: &mut String, st: Style, depth: Depth) { + b.push_str("\x1b[0"); + for (bit, code) in [(BOLD, ";1"), (FAINT, ";2"), (ITALIC, ";3"), (UNDERLINE, ";4"), (REVERSE, ";7")] { + if st.attr & bit != 0 { + b.push_str(code); + } + } + let col = |b: &mut String, c: Color, bg: bool| { + if c.is_default() { + return; + } + let _ = match depth { + Depth::None => Ok(()), + Depth::C256 => write!(b, ";{};5;{}", if bg { 48 } else { 38 }, quant256(c)), + Depth::C16 => { + let n = quant16(c) as u32; + let mut code = if n >= 8 { 90 + n - 8 } else { 30 + n }; + if bg { + code += 10; + } + write!(b, ";{code}") + } + Depth::True => write!(b, ";{};2;{};{};{}", if bg { 48 } else { 38 }, c.r(), c.g(), c.b()), + }; + }; + col(b, st.fg, false); + col(b, st.bg, true); + b.push('m'); +} diff --git a/rust/src/tui/color256.rs b/rust/src/tui/color256.rs new file mode 100644 index 0000000..8e5b056 --- /dev/null +++ b/rust/src/tui/color256.rs @@ -0,0 +1,92 @@ +//! The stock 256-colour conversion maps dark, slightly tinted colours to +//! saturated cube entries (olive, navy), which wrecks the subtle shading the +//! treemap relies on. quant256 picks the perceptually nearest entry (redmean +//! distance) among the cube and the grey ramp, never the palette-dependent +//! first 16 entries. + +use super::canvas::Color; +use std::cell::RefCell; +use std::collections::HashMap; + +const CUBE_LEVELS: [i32; 6] = [0, 95, 135, 175, 215, 255]; + +thread_local! { + static Q256: RefCell> = RefCell::new(HashMap::new()); + static Q16: RefCell> = RefCell::new(HashMap::new()); +} + +fn redmean(r1: i32, g1: i32, b1: i32, r2: i32, g2: i32, b2: i32) -> i32 { + let rm = (r1 + r2) / 2; + let (dr, dg, db) = (r1 - r2, g1 - g2, b1 - b2); + (((512 + rm) * dr * dr) >> 8) + 4 * dg * dg + (((767 - rm) * db * db) >> 8) +} + +pub(crate) fn quant256(c: Color) -> u8 { + if let Some(v) = Q256.with(|q| q.borrow().get(&c.0).copied()) { + return v; + } + let (r, g, b) = (c.r() as i32, c.g() as i32, c.b() as i32); + let (mut best, mut best_d) = (16, i32::MAX); + for i in 0..216 { + let (cr, cg, cb) = (CUBE_LEVELS[i / 36], CUBE_LEVELS[i / 6 % 6], CUBE_LEVELS[i % 6]); + let d = redmean(r, g, b, cr, cg, cb); + if d < best_d { + (best, best_d) = (16 + i, d); + } + } + for i in 0..24 { + let v = 8 + 10 * i as i32; + let d = redmean(r, g, b, v, v, v); + if d < best_d { + (best, best_d) = (232 + i, d); + } + } + Q256.with(|q| q.borrow_mut().insert(c.0, best as u8)); + best as u8 +} + +/// Maps a colour to a basic colour. Dark shades become black and +/// unsaturated colours grey/white, so tints and shadows stay quiet; +/// saturated colours keep their hue (bright variant when light), so category +/// colours survive. The stock conversion instead turns dark tints into +/// bright yellow/cyan. +pub(crate) fn quant16(c: Color) -> u8 { + if let Some(v) = Q16.with(|q| q.borrow().get(&c.0).copied()) { + return v; + } + let (rf, gf, bf) = c.comps(); + let (mx, mn) = (rf.max(gf).max(bf), rf.min(gf).min(bf)); + let luma = c.luma(); + let n = if luma < 0.2 { + 0 + } else if mx - mn < 0.12 { + // grey + if luma < 0.45 { + 8 + } else if luma < 0.8 { + 7 + } else { + 15 + } + } else { + // Hue in sixths: red, yellow, green, cyan, blue, magenta. + let mut h = if mx == rf { + ((gf - bf) / (mx - mn)) % 6.0 + } else if mx == gf { + (bf - rf) / (mx - mn) + 2.0 + } else { + (rf - gf) / (mx - mn) + 4.0 + }; + if h < 0.0 { + h += 6.0 + } + let ansi = [1u8, 3, 2, 6, 4, 5][h.round() as usize % 6]; + if luma > 0.5 { + ansi + 8 + } else { + ansi + } + }; + Q16.with(|q| q.borrow_mut().insert(c.0, n)); + n +} diff --git a/rust/src/tui/data.rs b/rust/src/tui/data.rs new file mode 100644 index 0000000..3b0f29b --- /dev/null +++ b/rust/src/tui/data.rs @@ -0,0 +1,181 @@ +//! Size lookups, the cached sorted child lists, selection upkeep and +//! colouring. + +use super::canvas::Color; +use super::model::{group_id, group_parent, is_group, Model, NO_SEL}; +use crate::inventory::{NodeId, Tree, FLAG_DELETED, NO_NODE}; +use crate::snapshot::Status; +use std::collections::HashSet; +use std::rc::Rc; + +/// A cached, size-sorted child list (zero-size entries removed). +#[derive(Default)] +pub(crate) struct Kids { + pub ids: Vec, + pub sizes: Vec, + pub total: i64, +} + +impl Model { + /// The size that drives layout: the filtered size when a filter is + /// active, otherwise the aggregate in the current size mode. + pub fn size_of(&self, tr: &Tree, id: NodeId) -> i64 { + if let Some(r) = &self.search.result { + return r.size(id); + } + let n = tr.node(id); + if n.has(FLAG_DELETED) { + return 0; + } + n.total(self.size_mode) + } + + /// The sorted children of id, cached until data changes. + pub fn kids_of(&mut self, tr: &Tree, id: NodeId) -> Rc { + if self.kids_ver != self.data_ver { + self.kids_cache.clear(); + self.kids_ver = self.data_ver; + } + if let Some(k) = self.kids_cache.get(&id) { + return k.clone(); + } + let mut v: Vec<(i64, NodeId)> = + tr.children(id).map(|(c, _)| (self.size_of(tr, c), c)).filter(|&(s, _)| s > 0).collect(); + v.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + let k = Rc::new(Kids { + total: v.iter().map(|e| e.0).sum(), + sizes: v.iter().map(|e| e.0).collect(), + ids: v.iter().map(|e| e.1).collect(), + }); + self.kids_cache.insert(id, k.clone()); + k + } + + /// The selected inventory node (NO_NODE for groups). + pub fn selected_node(&self, tr: &Tree) -> NodeId { + if self.sel == NO_SEL || is_group(self.sel) || self.sel >= tr.len() as i64 { + return NO_NODE; + } + self.sel as NodeId + } + + /// Keeps the selection meaningful after any change: if the selected node + /// is no longer a direct, visible child of the zoom root it falls back to + /// the visible ancestor, then the group containing it, then the largest + /// child. + pub fn ensure_selection(&mut self, tr: &Tree) { + let k = self.kids_of(tr, self.zoom); + if k.ids.is_empty() { + self.sel = NO_SEL; + return; + } + if !self.user_sel { + // Until the user picks something, follow the largest block (it + // changes while a scan is running). + self.sel = k.ids[0] as i64; + return; + } + if is_group(self.sel) { + if group_parent(self.sel) == self.zoom { + return; + } + self.sel = NO_SEL; + } + let id = self.selected_node(tr); + if id != NO_NODE { + // Walk up to the direct child of the zoom root. + let mut cur = id; + while cur != NO_NODE { + if tr.node(cur).parent == self.zoom { + if self.size_of(tr, cur) > 0 { + self.sel = cur as i64; + return; + } + break; + } + cur = tr.node(cur).parent; + } + } + self.sel = k.ids[0] as i64; + } + + /// Maps the selection onto the treemap blocks: a node collapsed into the + /// LOD group selects the group. + pub fn visible_sel(&self, blocks: &HashSet) -> i64 { + if blocks.contains(&self.sel) { + return self.sel; + } + if !is_group(self.sel) && blocks.contains(&group_id(self.zoom)) { + return group_id(self.zoom); + } + self.sel + } + + /// Makes dir the treemap root. + pub fn zoom_to(&mut self, tr: &Tree, dir: NodeId) { + if dir == self.zoom { + return; + } + let prev = self.zoom; + self.zoom = dir; + // Coming back out, keep the directory we left selected. + self.sel = if tr.is_ancestor(dir, prev) { prev as i64 } else { NO_SEL }; + self.user_sel = self.sel != NO_SEL; + self.zoomed = true; + self.dirty = true; + } + + pub fn set_view(&mut self, i: usize) { + if i < self.views.len() { + self.view = i; + self.dirty = true; + } + } + + /// The base colour for a block. + pub fn color_of(&self, tr: &Tree, id: i64) -> Color { + let t = &self.theme; + if is_group(id) { + return t.group; + } + let nid = id as NodeId; + let n = tr.node(nid); + if let Some(d) = &self.opts.diff { + let total = n.total(self.size_mode); + return match d.status_of(tr, nid, self.size_mode) { + Status::Added => t.diff_new, + Status::Grew => { + let old = d.old_size(nid, self.size_mode).unwrap_or(0); + t.diff_same.mix(t.diff_grow, ratio(total - old, old)) + } + Status::Shrank => { + let old = d.old_size(nid, self.size_mode).unwrap_or(0); + t.diff_same.mix(t.diff_shrink, ratio(old - total, old)) + } + _ => t.diff_same, + }; + } + if !n.is_dir() { + if let Some(c) = t.ext.as_ref().and_then(|e| e.get(tr.ext_name(n))) { + return *c; + } + return t.cat[n.cat as usize]; + } + let (mut best, mut best_v) = (None, 0); + for (i, &v) in tr.cat_sizes(nid).into_iter().flatten().enumerate() { + if v > best_v { + (best, best_v) = (Some(i), v); + } + } + best.map_or(t.empty_dir, |b| t.cat[b]) + } +} + +/// Maps a relative change to 0.35..1 for colour intensity. +fn ratio(delta: i64, base: i64) -> f64 { + if base <= 0 { + return 1.0; + } + let r = delta as f64 / base as f64; + (0.35 + r * 0.65).min(1.0) +} diff --git a/rust/src/tui/infoview.rs b/rust/src/tui/infoview.rs new file mode 100644 index 0000000..14c1d11 --- /dev/null +++ b/rust/src/tui/infoview.rs @@ -0,0 +1,160 @@ +//! The scan information view: statistics and the error list. + +use super::canvas::{Canvas, Style, BOLD}; +use super::model::{Cmd, Model}; +use super::table::{col, Table, TableRow}; +use super::{san, si_units}; +use crate::inventory::{ErrKind, Tree, FLAG_ALLOC_UNKNOWN}; +use crate::textutil; +use crate::treemap::Rect; + +#[derive(Default)] +pub(crate) struct InfoView { + pub tb: Table, +} + +impl InfoView { + pub fn focus_errors(&mut self) { + self.tb.cursor = 0; + } + + pub fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) { + let t = m.theme.clone(); + cv.fill(r, " ", t.base()); + let st = &tr.stats; + let root = tr.node(0); + let lw = if r.w < 100 { r.w - 2 } else { 52.min(r.w / 2) }; + let mut y = r.y + 1; + let x = r.x + 2; + let label = Style::new(t.muted, t.bg); + let value = Style::new(t.fg, t.bg).with(BOLD); + let line = |cv: &mut Canvas, y: &mut i32, k: &str, val: &str, vs: Style| { + if *y >= r.y + r.h { + return; + } + cv.text(x, *y, k, 20, label); + cv.text(x + 20, *y, val, lw - 20, vs); + *y += 1; + }; + let head = |cv: &mut Canvas, y: &mut i32, s: &str| { + if *y >= r.y + r.h - 1 { + return; + } + *y += 1; + cv.text(x, *y, s, lw, Style::new(t.accent, t.bg).with(BOLD)); + *y += 1; + }; + let (state, mut state_st) = ("Complete", Style::new(t.ok, t.bg).with(BOLD)); + let state = if m.scanning { + state_st.fg = t.accent; + "Scanning…" + } else if st.cancelled { + state_st.fg = t.warn; + "Cancelled — totals incomplete" + } else if st.incomplete() { + state_st.fg = t.warn; + "Complete, with unreadable areas" + } else { + state + }; + cv.text(x, y, "SCAN INFORMATION", lw, Style::new(t.accent, t.bg).with(BOLD)); + y += 1; + line(cv, &mut y, "Root", &san(&st.root), value); + line(cv, &mut y, "State", state, state_st); + if !st.from_snapshot.is_empty() { + line(cv, &mut y, "Snapshot", &textutil::sanitize(&st.from_snapshot), value); + } + let files = st.files + st.symlinks + st.others; + line(cv, &mut y, "Items scanned", &textutil::count(files + st.dirs), value); + line(cv, &mut y, " Files", &textutil::count(st.files), value); + line(cv, &mut y, " Directories", &textutil::count(st.dirs), value); + line(cv, &mut y, " Symlinks", &textutil::count(st.symlinks), value); + line(cv, &mut y, " Special", &textutil::count(st.others), value); + line(cv, &mut y, "Logical size", &format!("{} ({} bytes)", textutil::size(root.tot_size), textutil::count(root.tot_size)), value); + line(cv, &mut y, "Allocated size", &format!("{} ({} bytes)", textutil::size(root.tot_alloc), textutil::count(root.tot_alloc)), value); + let el = st.elapsed(); + if st.from_snapshot.is_empty() { + line(cv, &mut y, "Elapsed", &textutil::duration(el), value); + let rate = (files + st.dirs) as f64 / el.as_secs_f64().max(0.001); + line(cv, &mut y, "Rate", &(textutil::count(rate as i64) + " items/s"), value); + let cpus = std::thread::available_parallelism().map_or(1, |n| n.get()); + line(cv, &mut y, "Workers", &format!("{} ({} CPUs)", st.workers, cpus), value); + } + + head(cv, &mut y, "ERRORS AND OMISSIONS"); + let mut any_issue = false; + for (k, &c) in st.err_counts.iter().enumerate() { + if c > 0 { + line(cv, &mut y, ErrKind::from_u8(k as u8).name(), &textutil::count(c), Style::new(t.err, t.bg).with(BOLD)); + any_issue = true; + } + } + for (k, v) in [("Unscanned dirs", st.unscanned), ("Excluded", st.excluded), ("Other FS skipped", st.skipped_mounts), + ("Virtual FS skipped", st.virtual_skipped), ("Loops skipped", st.loops_skipped), ("Broken symlinks", st.broken_links), + ("Hard-link dups", st.hardlink_dups)] + { + if v > 0 { + line(cv, &mut y, k, &textutil::count(v), Style::new(t.warn, t.bg).with(BOLD)); + any_issue = true; + } + } + if !any_issue { + line(cv, &mut y, "None", "every entry was read", Style::new(t.ok, t.bg)); + } + head(cv, &mut y, "OPTIONS"); + let excl = if st.excludes.is_empty() { "none".to_string() } else { textutil::sanitize(&st.excludes.join(", ")) }; + line(cv, &mut y, "Exclusions", &excl, value); + line(cv, &mut y, "One filesystem", &st.one_file_system.to_string(), value); + line(cv, &mut y, "Follow symlinks", &textutil::sanitize(&st.follow), value); + line(cv, &mut y, "Size mode", m.size_mode.name(), value); + line(cv, &mut y, "Units", if si_units() { "SI (1000)" } else { "IEC (1024)" }, value); + if root.has(FLAG_ALLOC_UNKNOWN) { + line(cv, &mut y, "Note", "allocated size unavailable on this platform", Style::new(t.warn, t.bg)); + } + + // Error list on the right (or below in narrow terminals). + let er = if r.w < 100 { + Rect { x: r.x, y: y + 1, w: r.w, h: r.y + r.h - y - 1 } + } else { + Rect { x: r.x + lw + 4, y: r.y, w: r.w - lw - 4, h: r.h } + }; + if er.w < 30 || er.h < 4 { + return; + } + let rows: Vec = st + .errors + .iter() + .map(|e| { + let mut p = tr.path_bytes(e.node); + if !e.name.is_empty() { + p.push(b'/'); + p.extend_from_slice(&e.name); + } + TableRow { + cells: vec![e.kind.name().into(), san(&p), textutil::sanitize(&e.msg)], + styles: vec![Some(Style::new(t.err, t.bg)), None, Some(Style::new(t.muted, t.bg))], + bar: -1.0, + ..Default::default() + } + }) + .collect(); + let mut title = format!("ERROR LIST ({})", textutil::count(st.total_errors())); + if (st.errors.len() as i64) < st.total_errors() { + title += &format!(" — first {} shown", textutil::count(st.errors.len() as i64)); + } + let cols = [col("KIND", 18, false, false), col("PATH", 0, false, true), col("DETAIL", 22, false, false)]; + self.tb.paint(m, cv, er, &cols, &rows, &title); + } + + pub fn key(&mut self, m: &mut Model, tr: &Tree, k: &str) -> (bool, Cmd) { + let errs = &tr.stats.errors; + if self.tb.key(k, errs.len()) { + return (true, Cmd::None); + } + if self.tb.at() < errs.len() && (k == "enter" || k == "space") { + m.jump_to(tr, errs[self.tb.at()].node); + return (true, Cmd::None); + } + (false, Cmd::None) + } +} diff --git a/rust/src/tui/keys.rs b/rust/src/tui/keys.rs new file mode 100644 index 0000000..3dcfbb0 --- /dev/null +++ b/rust/src/tui/keys.rs @@ -0,0 +1,228 @@ +//! Key dispatch and the actions keys trigger. + +use super::model::{is_group, Cmd, Key, Model, Msg}; +use super::modals::{new_group_modal, new_help_modal, new_info_modal, new_save_modal}; +use super::theme::{load_theme, theme_names}; +use super::views::View; +use super::{fmt_stamp_now, san}; +use crate::brand; +use crate::inventory::{NodeId, SizeMode, Tree, NO_NODE}; +use crate::platform; +use crate::snapshot; +use crate::textutil; +use std::ffi::OsStr; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; +use std::rc::Rc; + +impl Model { + /// Dispatches a key press. It holds the tree read lock for the whole + /// handler; helpers it calls must not lock again (a recursive read lock + /// can deadlock against a waiting writer). + pub fn handle_key(&mut self, key: &Key) -> Cmd { + let k = key.name.as_str(); + if k == "ctrl+c" { + return self.ctrl_c_pressed(); + } + let arc = self.tree.clone(); + let tr = arc.read().unwrap(); + self.ensure_selection(&tr); + if !self.modals.is_empty() { + return self.modal_key(&tr, key); + } + if self.search.editing { + return self.search_key(key); + } + let (handled, cmd) = self.view_key(&tr, k); + if handled { + return cmd; + } + match k { + "q" => self.quit = true, + "?" | "f1" => { + let md = new_help_modal(); + self.open_modal(md); + } + "/" => self.search.begin(), + "esc" => { + if self.search.query.is_some() { + self.clear_filter(); + } else if self.zoom != 0 { + self.zoom_out(&tr); + } + } + "tab" => self.set_view((self.view + 1) % self.views.len()), + "shift+tab" => self.set_view((self.view + self.views.len() - 1) % self.views.len()), + "1" | "2" | "3" | "4" | "5" | "6" | "7" => self.set_view((k.as_bytes()[0] - b'1') as usize), + "enter" => return self.inspect_selection(&tr), + "space" | "right" => return self.zoom_into_selection(&tr), + "backspace" | "left" => self.zoom_out(&tr), + "home" => self.zoom_to(&tr, 0), + "a" => { + self.size_mode = if self.size_mode == SizeMode::Allocated { SizeMode::Logical } else { SizeMode::Allocated }; + self.invalidate(); + let mut cmds = Vec::new(); + if self.search.query.is_some() { + cmds.push(self.apply_filter()); + } + let msg = format!("Sizing by {} size", self.size_mode.name()); + cmds.push(self.info(msg)); + return Cmd::batch(cmds); + } + "r" => return self.rescan(&tr), + "s" => { + let md = new_save_modal(&tr); + self.open_modal(md); + } + "e" => { + self.set_view_by_name("Scan"); + if let View::Info(v) = &mut self.views[self.view] { + v.focus_errors(); + } + } + "x" => self.set_view_by_name("Types"), + "g" => self.set_view_by_name("Top"), + "D" => return self.start_duplicates(), + "c" => return self.copy_selected(&tr), + "o" => return self.reveal(&tr), + "d" | "delete" => return self.request_trash(&tr), + "T" => { + let names = theme_names(); + let cur = names.iter().position(|n| *n == self.theme.name).unwrap_or(0); + let ascii = self.theme.ascii; + let mut th = load_theme(names[(cur + 1) % names.len()], &self.opts.settings); + th.ascii = ascii; + self.theme = Rc::new(th); + let msg = format!("Theme: {}", self.theme.name); + return self.info(msg); + } + _ => {} + } + Cmd::None + } + + pub fn inspect_selection(&mut self, tr: &Tree) -> Cmd { + if is_group(self.sel) { + if let Some(g) = self.group_info(self.sel) { + self.open_modal(new_group_modal(g)); + } + return Cmd::None; + } + let id = self.selected_node(tr); + if id != NO_NODE { + let md = new_info_modal(self, tr, id); + self.open_modal(md); + } + Cmd::None + } + + pub fn zoom_into_selection(&mut self, tr: &Tree) -> Cmd { + if is_group(self.sel) { + // Groups cannot be zoomed: show their members in the list view. + if let Some(g) = self.group_info(self.sel) { + if let Some(&first) = g.ids().first() { + self.sel = first as i64; + self.set_view_by_name("List"); + } + } + return Cmd::None; + } + let id = self.selected_node(tr); + if id == NO_NODE { + return Cmd::None; + } + if !tr.node(id).is_dir() { + return self.info("Not a directory — Enter shows details"); + } + if self.kids_of(tr, id).ids.is_empty() { + return self.info("Directory is empty"); + } + self.zoom_to(tr, id); + Cmd::None + } + + pub fn zoom_out(&mut self, tr: &Tree) { + let p = tr.node(self.zoom).parent; + if p != NO_NODE { + self.zoom_to(tr, p); + } + } + + /// Zooms to a node's parent directory and selects it. + pub fn jump_to(&mut self, tr: &Tree, id: NodeId) { + let p = tr.node(id).parent; + if p == NO_NODE { + self.zoom_to(tr, id); + return; + } + self.zoom_to(tr, p); + self.sel = id as i64; + self.set_view_by_name("Map"); + } + + fn copy_selected(&mut self, tr: &Tree) -> Cmd { + let id = self.selected_node(tr); + if id == NO_NODE { + return Cmd::None; + } + self.copy_path(&tr.path_bytes(id)) + } + + /// Puts p on the clipboard (OSC 52). Paths that would not survive + /// display unchanged are refused: a newline in a hostile filename runs + /// commands when pasted into a shell, and bidi controls disguise them. + pub fn copy_path(&mut self, p: &[u8]) -> Cmd { + let s = san(p); + if s.as_bytes() != p { + return self.warn("Not copied: path contains control or bidi characters"); + } + let i = self.info("Copied path to clipboard (OSC 52)"); + Cmd::batch(vec![Cmd::Clipboard(s), i]) + } + + pub fn reveal(&mut self, tr: &Tree) -> Cmd { + if self.snapshot { + return self.warn("Snapshot — the files may not exist on this machine"); + } + let id = self.selected_node(tr); + if id == NO_NODE { + return Cmd::None; + } + if platform::headless() { + // No file manager: show the folder in the map instead. + if tr.node(id).is_dir() && !self.kids_of(tr, id).ids.is_empty() { + self.zoom_to(tr, id); + self.set_view_by_name("Map"); + } else { + self.jump_to(tr, id); + } + let msg = format!("Showing {}", san(&tr.path_bytes(self.zoom))); + return self.info(msg); + } + if let Err(e) = platform::reveal(&tr.path(id)) { + return self.warn(format!("Open failed: {}", textutil::sanitize(&e.to_string()))); + } + self.info("Opened in file manager") + } + + pub fn save_snapshot(&mut self, path: String) -> Cmd { + let t = self.tree.clone(); + Cmd::run(move || { + let err = { + let g = t.read().unwrap(); + snapshot::save_file(Path::new(&path), &g).err().map(|e| e.to_string()) + }; + Msg::SaveDone { path, err } + }) + } +} + +pub(crate) fn default_snapshot_name(root: &[u8]) -> String { + let p = Path::new(OsStr::from_bytes(root)); + let base = match p.file_name() { + Some(b) => String::from_utf8_lossy(b.as_bytes()).into_owned(), + None => "root".into(), + }; + let wd = std::env::current_dir().unwrap_or_default(); + wd.join(format!("{base}-{}{}", fmt_stamp_now(), brand::SNAPSHOT_EXT)).to_string_lossy().into_owned() +} diff --git a/rust/src/tui/mapview.rs b/rust/src/tui/mapview.rs new file mode 100644 index 0000000..d78aa01 --- /dev/null +++ b/rust/src/tui/mapview.rs @@ -0,0 +1,383 @@ +//! The treemap view: layout with level-of-detail grouping, nested previews +//! inside directories, painting and spatial navigation. + +use super::canvas::{rgb, Canvas, Style, BOLD, BOX_ROUND, FAINT}; +use super::data::Kids; +use super::model::{group_id, is_group, Cmd, Hit, HitAct, Model}; +use super::render::kind_label; +use super::{san, trunc, tw}; +use crate::inventory::{NodeId, Tree, NO_NODE}; +use crate::textutil; +use crate::treemap::{self, Block, Direction, Item, Rect}; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +/// The treemap geometry for one (zoom, rect, data version). It is recomputed +/// from scratch whenever any of those change — after a resize the layout +/// algorithm runs again for the new rectangle rather than scaling the old +/// one. +pub(crate) struct TmLayout { + pub zoom: NodeId, + pub rect: Rect, + pub ver: u64, + pub blocks: Vec, // top level: navigable + pub nested: Vec, // previews inside directories: drawn only + pub groups: HashMap, + pub visible: HashSet, + pub inner: HashSet, // top-level blocks with nested previews +} + +#[derive(Clone, Copy)] +pub(crate) struct Nested { + pub id: i64, + pub r: Rect, + pub depth: i32, + pub alt: bool, + pub inner: bool, // has nested children drawn inside it +} + +/// An LOD group: the children of parent from index `from` on. +#[derive(Clone)] +pub(crate) struct Group { + pub parent: NodeId, + kids: Rc, + from: usize, + pub size: i64, +} + +impl Group { + pub fn ids(&self) -> &[NodeId] { + &self.kids.ids[self.from..] + } +} + +// LOD parameters. +pub(crate) const TOP_MIN_AREA: i32 = 12; // cells: room for a 4×3 framed block +const TOP_MAX_ITEMS: usize = 400; +const NEST_MIN_AREA: i32 = 3; +const NEST_MAX_ITEMS: usize = 120; +const MAX_NEST_DEPTH: i32 = 8; // block-size thresholds stop recursion long before this +const NEST_MIN_W: i32 = 4; +const NEST_MIN_H: i32 = 2; +const NEST_RECURSE_W: i32 = 14; +const NEST_RECURSE_H: i32 = 5; + +impl Model { + pub fn layout_treemap(&mut self, tr: &Tree, r: Rect) -> Rc { + if let Some(l) = &self.tm { + if l.zoom == self.zoom && l.rect == r && l.ver == self.data_ver { + return l.clone(); + } + } + let mut l = TmLayout { + zoom: self.zoom, rect: r, ver: self.data_ver, blocks: Vec::new(), nested: Vec::new(), + groups: HashMap::new(), visible: HashSet::new(), inner: HashSet::new(), + }; + l.blocks = self.layout_level(tr, &mut l, self.zoom, r, TOP_MIN_AREA, TOP_MAX_ITEMS); + for i in 0..l.blocks.len() { + let b = l.blocks[i]; + l.visible.insert(b.id); + if b.id < 0 || b.rect.w < 3 || b.rect.h < 2 { + continue; + } + if tr.node(b.id as NodeId).is_dir() { + let inn = b.rect.inset(1); + if inn.w >= NEST_MIN_W && inn.h >= NEST_MIN_H { + let before = l.nested.len(); + let end = self.chain_end(tr, b.id as NodeId); + self.layout_nested(tr, &mut l, end, inn, 2); + if l.nested.len() > before { + l.inner.insert(b.id); + } + } + } + } + let l = Rc::new(l); + self.tm = Some(l.clone()); + l + } + + /// Lays out the children of parent in r with level-of-detail grouping: + /// children too small to be useful collapse into one group block. + fn layout_level(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, min_area: i32, max_items: usize) -> Vec { + let k = self.kids_of(tr, parent); + if k.ids.is_empty() || r.empty() { + return Vec::new(); + } + let sizes: Vec = k.sizes.iter().map(|&s| s as f64).collect(); + let vis = treemap::visible(&sizes, k.total as f64, r.area(), min_area, max_items); + let mut items: Vec = (0..vis).map(|i| Item { id: k.ids[i] as i64, size: sizes[i] }).collect(); + if vis < k.ids.len() { + let size = k.sizes[vis..].iter().sum(); + let gid = group_id(parent); + l.groups.insert(gid, Group { parent, kids: k.clone(), from: vis, size }); + items.push(Item { id: gid, size: size as f64 }); + } + treemap::squarify(&items, r) + } + + fn layout_nested(&mut self, tr: &Tree, l: &mut TmLayout, parent: NodeId, r: Rect, depth: i32) { + // Deeper levels need bigger blocks to stay legible rather than noisy. + let blocks = self.layout_level(tr, l, parent, r, NEST_MIN_AREA * (depth - 1), NEST_MAX_ITEMS); + for (i, b) in blocks.iter().enumerate() { + let idx = l.nested.len(); + l.nested.push(Nested { id: b.id, r: b.rect, depth, alt: i % 2 == 1, inner: false }); + let br = b.rect; + if depth < MAX_NEST_DEPTH && b.id >= 0 && br.w >= NEST_RECURSE_W && br.h >= NEST_RECURSE_H && tr.node(b.id as NodeId).is_dir() { + l.nested[idx].inner = true; + let end = self.chain_end(tr, b.id as NodeId); + self.layout_nested(tr, l, end, Rect { x: br.x, y: br.y + 1, w: br.w - 1, h: br.h - 2 }, depth + 1); + } + } + } + + /// Follows directories whose only non-empty child is a directory + /// (mnt → raid → media), so nesting spends its space on the level that + /// actually branches instead of on full-size wrappers. + pub fn chain_end(&mut self, tr: &Tree, mut id: NodeId) -> NodeId { + loop { + let k = self.kids_of(tr, id); + if k.ids.len() != 1 || !tr.node(k.ids[0]).is_dir() { + return id; + } + id = k.ids[0]; + } + } + + /// The LOD group for a group ID in the current layout. + pub fn group_info(&self, id: i64) -> Option { + self.tm.as_ref()?.groups.get(&id).cloned() + } + + /// A sanitised display name for a block. + pub fn block_name(&mut self, tr: &Tree, id: i64) -> String { + if is_group(id) { + return match self.group_info(id) { + Some(g) => textutil::count(g.ids().len() as i64) + " smaller items", + None => "smaller items".into(), + }; + } + let mut nid = id as NodeId; + let n = tr.node(nid); + let mut name = san(&n.name); + if n.is_dir() && nid != 0 { + name.push('/'); + let end = self.chain_end(tr, nid); + while nid != end { + nid = self.kids_of(tr, nid).ids[0]; + name += &san(&tr.node(nid).name); + name.push('/'); + } + } + name + } + + fn block_size(&self, tr: &Tree, id: i64) -> i64 { + if is_group(id) { + return self.group_info(id).map_or(0, |g| g.size); + } + self.size_of(tr, id as NodeId) + } + + /// The size of the folder a block sits in. + fn parent_size(&self, tr: &Tree, id: i64) -> i64 { + if is_group(id) { + return self.group_info(id).map_or(0, |g| self.size_of(tr, g.parent)); + } + let p = tr.node(id as NodeId).parent; + if p != NO_NODE { + return self.size_of(tr, p); + } + 0 + } + + fn paint_top(&mut self, tr: &Tree, cv: &mut Canvas, b: Block, sel: bool, hov: bool, total: i64) { + let t = self.theme.clone(); + let base = self.color_of(tr, b.id); + let fill = base.mix(t.bg, 0.62); + let fill_ch = if !is_group(b.id) { + " " + } else if t.ascii { + "." + } else { + "░" + }; + let name = self.block_name(tr, b.id); + let size = self.block_size(tr, b.id); + let r = b.rect; + if r.w < 3 || r.h < 2 { + // Too small for a frame: a solid tile. + let mut st = Style::new(base, base.mix(t.bg, 0.3)); + let mut ch = fill_ch; + if sel { + st = Style::new(t.text_on(t.sel), t.sel).with(BOLD | self.mono_rev()); + ch = if t.ascii { "#" } else { "◆" }; + } else if t.mono { + ch = "▒"; + } + cv.fill(r, " ", st); + cv.text(r.x + (r.w - 1) / 2, r.y + (r.h - 1) / 2, ch, 1, st); + return; + } + cv.fill(r, fill_ch, Style::new(base.mix(t.bg, 0.35), fill)); + let mut frame = Style::new(base.mix(t.bg, 0.15), fill); + let mut bx = t.box_chars(); + let mut title = Style::new(base.mix(rgb(255, 255, 255), 0.35), fill).with(BOLD); + if sel { + bx = t.sel_box(); + frame = Style::new(t.sel, fill).with(BOLD); + title = Style::new(t.text_on(t.sel), t.sel).with(BOLD | self.mono_rev()); + } else if hov { + frame.fg = base.mix(rgb(255, 255, 255), 0.5); + } + if t.mono && !sel { + title.attr = BOLD; + } + cv.draw_box(r, bx, frame); + cv.text(r.x + 1, r.y, &trunc(&format!(" {name} "), r.w - 2), r.w - 2, title); + + let mut label = textutil::size(size); + let pct = textutil::percent(size, total); + if r.w - 2 >= tw(&label) + tw(&pct) + 5 { + label += &format!(" · {pct}"); + } else if r.w - 2 < tw(&label) + 2 { + label = textutil::size_compact(size); + } + let label = format!(" {label} "); + if r.h >= 3 && tw(&label) <= r.w - 2 { + cv.text_right(r.x + 1, r.y + r.h - 1, r.w - 2, &label, Style { fg: frame.fg, bg: fill, attr: frame.attr }); + } + // Interior labels for blocks without nested previews. + let inn = r.inset(1); + if inn.empty() || self.has_nested(b.id) { + return; + } + let mut lines = Vec::new(); + if !is_group(b.id) { + let nd = tr.node(b.id as NodeId); + if !nd.is_dir() { + lines.push(kind_label(nd)); + } else if nd.files > 0 { + lines.push(textutil::count(nd.files as i64) + " files"); + } + } + if r.h < 4 || inn.w < 6 { + return; + } + let y0 = inn.y + (inn.h - lines.len() as i32) / 2; + for (i, s) in lines.iter().enumerate() { + cv.text_centered(inn.x, y0 + i as i32, inn.w, s, Style::new(base.mix(t.fg, 0.4), fill)); + } + } + + fn has_nested(&self, id: i64) -> bool { + self.tm.as_ref().is_some_and(|l| l.inner.contains(&id)) + } + + fn paint_nested(&mut self, tr: &Tree, cv: &mut Canvas, nb: &Nested) { + let t = self.theme.clone(); + let base = self.color_of(tr, nb.id); + let mut f = (0.38 - 0.1 * (nb.depth - 2) as f64).max(0.1); + if nb.alt { + f += 0.07; + } + let bg = base.mix(t.bg, f); + let shadow = bg.mix(rgb(0, 0, 0), 0.45); + let mut st = Style::new(t.text_on(bg), bg); + let mut ch = " "; + if is_group(nb.id) { + ch = if t.ascii { "." } else { "░" }; + st.fg = bg.mix(t.fg, 0.25); + } + let r = nb.r; + cv.fill(r, ch, st); + if t.mono { + // No colour: draw a light outline so neighbours stay distinguishable. + if r.w >= 2 && r.h >= 2 { + cv.draw_box(r, BOX_ROUND, Style::default().with(FAINT)); + } + } else { + if r.w >= 4 { + cv.fill(Rect { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, " ", Style::new(Default::default(), shadow)); + } + if r.h >= 3 { + cv.fill(Rect { x: r.x, y: r.y + r.h - 1, w: r.w, h: 1 }, " ", Style::new(Default::default(), shadow)); + } + } + let lw = if t.mono { r.w - 2 } else { r.w - 1 }; + if lw < 5 { + return; + } + let lx = if t.mono { r.x + 1 } else { r.x }; + let ly = if t.mono && r.h >= 3 { r.y + 1 } else { r.y }; + let name = self.block_name(tr, nb.id); + if tw(&name) > lw && lw < 8 { + return; // a stub like "te…" is noise, not information + } + cv.text(lx, ly, &trunc(&name, lw), lw, Style::new(st.fg, bg).with(BOLD)); + if !nb.inner && r.h >= 3 && lw >= 5 { + let size = self.block_size(tr, nb.id); + let mut label = textutil::size_compact(size); + let pct = textutil::percent(size, self.parent_size(tr, nb.id)); + if tw(&label) + tw(&pct) + 3 <= lw { + label += &format!(" · {pct}"); + } + cv.text(lx, ly + 1, &trunc(&label, lw), lw, Style::new(bg.mix(st.fg, 0.7), bg)); + } + } +} + +pub(crate) fn map_paint(m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) { + let t = m.theme.clone(); + let l = m.layout_treemap(tr, r); + if l.blocks.is_empty() { + let z = tr.node(m.zoom); + let msg = if m.search.query.is_some() { + "No matches here — Esc clears the filter".to_string() + } else if m.scanning { + "Scanning…".to_string() + } else if !z.is_dir() { + format!("{} — {}", san(&z.name), textutil::size(m.size_of(tr, m.zoom))) + } else { + "Empty directory".to_string() + }; + cv.text_centered(r.x, r.y + r.h / 2, r.w, &msg, t.muted()); + return; + } + let sel = m.visible_sel(&l.visible); + let total = m.size_of(tr, m.zoom); + for b in &l.blocks { + let hov = b.id == m.hover; + m.paint_top(tr, cv, *b, b.id == sel, hov, total); + } + for nb in &l.nested { + m.paint_nested(tr, cv, nb); + } + for b in &l.blocks { + m.hits.push(Hit { r: b.rect, act: HitAct::Block(b.id) }); + } +} + +pub(crate) fn map_key(m: &mut Model, k: &str) -> (bool, Cmd) { + let d = match k { + "left" | "h" => Direction::Left, + "right" | "l" => Direction::Right, + "up" | "k" => Direction::Up, + "down" | "j" => Direction::Down, + _ => return (false, Cmd::None), + }; + if let Some(l) = m.tm.clone() { + if let Some(id) = treemap::neighbour(&l.blocks, m.visible_sel(&l.visible), d) { + m.sel = id; + } + } + (true, Cmd::None) +} + +pub(crate) fn map_wheel(m: &mut Model, tr: &Tree, up: bool) -> Cmd { + if up { + return m.zoom_into_selection(tr); + } + m.zoom_out(tr); + Cmd::None +} diff --git a/rust/src/tui/mod.rs b/rust/src/tui/mod.rs new file mode 100644 index 0000000..0703e7f --- /dev/null +++ b/rust/src/tui/mod.rs @@ -0,0 +1,88 @@ +//! The interactive terminal interface. All drawing goes to a Canvas; the +//! event loop (run.rs) only transports the finished frame to the terminal. + +mod actions; +pub mod bench; +mod canvas; +mod color256; +mod data; +mod infoview; +mod keys; +mod mapview; +mod modal; +mod modals; +mod model; +mod mouse; +mod render; +mod run; +mod search; +mod side; +mod table; +mod theme; +mod views; + +#[cfg(test)] +mod tests; + +pub use model::{Options, StartFn}; +pub use run::run; + +use crate::textutil; + +// Width-aware text helpers taking the canvas's signed cell counts. + +pub(crate) fn tw(s: &str) -> i32 { + textutil::width(s) as i32 +} + +pub(crate) fn trunc(s: &str, w: i32) -> String { + textutil::truncate(s, w.max(0) as usize) +} + +pub(crate) fn trunc_left(s: &str, w: i32) -> String { + textutil::truncate_left(s, w.max(0) as usize) +} + +pub(crate) fn pad_left(s: &str, w: i32) -> String { + textutil::pad_left(s, w.max(0) as usize) +} + +pub(crate) fn pad_right(s: &str, w: i32) -> String { + textutil::pad_right(s, w.max(0) as usize) +} + +pub(crate) fn san(b: &[u8]) -> String { + textutil::sanitize_bytes(b) +} + +/// Whether decimal (SI) units are selected. textutil keeps the switch +/// private, so it is inferred from the formatting. +pub(crate) fn si_units() -> bool { + textutil::size(1000).ends_with("kB") +} + +fn local_tm(unix_nanos: i64) -> libc::tm { + let secs = unix_nanos.div_euclid(1_000_000_000) as libc::time_t; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + unsafe { libc::localtime_r(&secs, &mut tm) }; + tm +} + +/// Formats a timestamp as a local date ("2006-01-02"). +pub(crate) fn fmt_date(unix_nanos: i64) -> String { + let t = local_tm(unix_nanos); + format!("{:04}-{:02}-{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday) +} + +/// Formats a timestamp as local date and time ("2006-01-02 15:04:05"). +pub(crate) fn fmt_time(unix_nanos: i64) -> String { + let t = local_tm(unix_nanos); + format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec) +} + +/// The current local time as "20060102-1504". +pub(crate) fn fmt_stamp_now() -> String { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let t = local_tm(now.as_nanos() as i64); + format!("{:04}{:02}{:02}-{:02}{:02}", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min) +} diff --git a/rust/src/tui/modal.rs b/rust/src/tui/modal.rs new file mode 100644 index 0000000..e10ec70 --- /dev/null +++ b/rust/src/tui/modal.rs @@ -0,0 +1,229 @@ +//! The generic modal frame: a centred, clamped, scrollable overlay. + +use super::canvas::{rgb, Canvas, Style, BOLD, BOX_ASCII, BOX_ROUND, FAINT}; +use super::model::{Cmd, Hit, HitAct, Key, Model}; +use super::pad_right; +use super::search::LineEditor; +use crate::inventory::Tree; +use crate::textutil; +use crate::treemap::Rect; + +/// A run of styled text; a line is a list of spans. +pub(crate) struct Span { + pub s: String, + pub st: Option