Files
mapsize/src/tui/render.rs
T

379 lines
14 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.
//! 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(),
}
}