Files
mapsize/src/tui/modals.rs
T

439 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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 crate::textutil::si() { "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)
}