pub mod kitty;
pub mod paint;
pub mod tiles;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::{self, Write};
use std::time::{Duration, Instant};
use ratatui::layout::Rect;
use super::state::View;
use kitty::Image;
use tiles::Area;
pub const MIN_WIDTH: u16 = 100;
pub const MIN_HEIGHT: u16 = 12;
const SHARE: (f32, u16, u16) = (0.36, 32, 56);
const SETTLE: Duration = Duration::from_millis(250);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Maps {
Can,
#[default]
Unread,
Unmeasured,
}
impl Maps {
fn of(reads: bool, cell: Option<(u16, u16)>) -> Self {
match (reads, cell) {
(false, _) => Self::Unread,
(true, None) => Self::Unmeasured,
(true, Some(_)) => Self::Can,
}
}
#[must_use]
pub fn can(self) -> bool {
matches!(self, Self::Can)
}
#[must_use]
pub fn why(self) -> Option<&'static str> {
match self {
Self::Can => None,
Self::Unread => {
Some("this terminal does not read the graphics protocol, so there is no map")
}
Self::Unmeasured => Some(
"this terminal reports no pixel size — tmux and screen do not pass one on — \
so there is no map",
),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Drawn {
Map,
Nothing,
Cannot(Maps),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Pane {
pub cells: Rect,
pub cell: (u16, u16),
}
impl Pane {
#[must_use]
pub fn pixels(&self) -> Option<(u32, u32)> {
if self.cell.0 == 0 || self.cell.1 == 0 {
return None;
}
let across = u32::from(self.cells.width) * u32::from(self.cell.0);
let down = u32::from(self.cells.height) * u32::from(self.cell.1);
(across > 0 && down > 0).then_some((across, down))
}
#[must_use]
pub fn width_in(width: u16) -> Option<u16> {
if width < MIN_WIDTH {
return None;
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a share of a terminal width, clamped into u16 bounds either side"
)]
let want = (f32::from(width) * SHARE.0) as u16;
Some(want.clamp(SHARE.1, SHARE.2).min(width / 2))
}
}
#[derive(Debug)]
pub struct Screen<W: Write> {
out: W,
allowed: bool,
up: bool,
steering: u64,
arriving: u64,
since: Option<Instant>,
}
impl<W: Write> Screen<W> {
pub fn new(out: W, allowed: bool) -> Self {
Self {
out,
allowed,
up: false,
steering: 0,
arriving: 0,
since: None,
}
}
#[must_use]
pub fn mapping(&self, cell: Option<(u16, u16)>) -> Maps {
Maps::of(self.allowed, cell)
}
pub fn show(&mut self, view: &View, pane: Pane, now: Instant) -> io::Result<Drawn> {
if !self.allowed {
return Ok(Drawn::Cannot(Maps::Unread));
}
let Some((width, height)) = pane.pixels() else {
self.hide()?;
return Ok(Drawn::Cannot(Maps::Unmeasured));
};
let Some(root) = tiles::focus(view) else {
self.hide()?;
return Ok(Drawn::Nothing);
};
let area = Area::of(f64::from(width), f64::from(height));
if !tiles::mappable(view, root, area) {
self.hide()?;
return Ok(Drawn::Nothing);
}
let steering = fingerprint(&(
root,
view.row().map(|row| row.id),
view.mark_stamp(),
view.lens(),
pane.cells,
pane.cell,
));
let arriving = view.map_stamp(root);
let steered = steering != self.steering;
let settled = self
.since
.is_none_or(|last| now.saturating_duration_since(last) >= SETTLE);
if self.up && !steered && arriving == self.arriving {
return Ok(Drawn::Map);
}
if self.up && !steered && !settled {
return Ok(Drawn::Map);
}
let Some(map) = tiles::plan(view, root, area) else {
self.hide()?;
return Ok(Drawn::Nothing);
};
let canvas = paint::paint(&map, width, height);
let at = (pane.cells.y + 1, pane.cells.x + 1);
let cells = (pane.cells.width, pane.cells.height);
self.up = true;
self.put(&Image::shown(&canvas, at, cells))?;
self.steering = steering;
self.arriving = arriving;
self.since = Some(now);
Ok(Drawn::Map)
}
pub fn hide(&mut self) -> io::Result<()> {
if !self.up {
return Ok(());
}
self.steering = 0;
self.arriving = 0;
self.since = None;
self.put(&Image::gone())?;
self.up = false;
Ok(())
}
pub fn restore(&mut self) -> io::Result<()> {
self.hide()
}
fn put(&mut self, bytes: &[u8]) -> io::Result<()> {
self.out.write_all(bytes)?;
self.out.flush()
}
#[cfg(test)]
pub(crate) fn sink(&self) -> &W {
&self.out
}
}
fn fingerprint(of: &impl Hash) -> u64 {
let mut hasher = DefaultHasher::new();
of.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::{Drawn, MIN_WIDTH, Maps, Pane, SETTLE, Screen, kitty, paint, tiles};
use crate::fixture::{gitignored, hit, priced};
use crate::size::Size;
use crate::tree::Tree;
use crate::tui::keymap::{Action, Motion};
use crate::tui::state::View;
use crate::tui::treemap::tiles::Area;
use ratatui::layout::Rect;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
fn view() -> View {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/nx/node_modules", 8 * 1024 * 1024));
tree.insert(priced("/scan/pua/target", 2 * 1024 * 1024));
let mut view = View::new(tree);
view.allow_maps(Maps::Can);
view.sync();
view
}
fn pane() -> Pane {
Pane {
cells: Rect::new(60, 1, 40, 30),
cell: (9, 19),
}
}
fn screen() -> Screen<Vec<u8>> {
Screen::new(Vec::new(), true)
}
fn written(screen: &Screen<Vec<u8>>) -> String {
String::from_utf8_lossy(screen.sink()).into_owned()
}
#[test]
fn a_terminal_that_is_not_known_to_read_the_protocol_is_written_nothing() {
let mut screen = Screen::new(Vec::new(), false);
let view = view();
screen.show(&view, pane(), Instant::now()).unwrap();
screen.hide().unwrap();
screen.restore().unwrap();
assert_eq!(written(&screen), "", "an escape reached a terminal");
assert_eq!(screen.mapping(Some((9, 19))), Maps::Unread);
}
#[test]
fn a_terminal_that_reports_no_pixel_size_gets_no_map() {
let mut screen = screen();
let view = view();
let drawn = screen
.show(
&view,
Pane {
cell: (0, 0),
..pane()
},
Instant::now(),
)
.unwrap();
assert_eq!(written(&screen), "");
assert_eq!(drawn, Drawn::Cannot(Maps::Unmeasured));
}
#[test]
fn both_gates_come_back_as_one_answer_that_names_which_of_them_refused() {
let screen = screen();
assert_eq!(screen.mapping(Some((9, 19))), Maps::Can);
assert_eq!(screen.mapping(None), Maps::Unmeasured);
assert!(Maps::Can.can());
assert!(!Maps::Unmeasured.can() && !Maps::Unread.can());
let unmeasured = Maps::Unmeasured.why().unwrap();
assert!(unmeasured.contains("pixel size"), "{unmeasured}");
assert_ne!(unmeasured, Maps::Unread.why().unwrap());
assert_eq!(Maps::Can.why(), None);
assert_eq!(Maps::default(), Maps::Unread);
}
#[test]
fn a_still_frame_says_the_map_is_up_rather_than_saying_nothing_at_all() {
let mut screen = screen();
let view = view();
let now = Instant::now();
assert_eq!(screen.show(&view, pane(), now).unwrap(), Drawn::Map);
let first = screen.sink().len();
assert_eq!(screen.show(&view, pane(), now).unwrap(), Drawn::Map);
assert_eq!(
screen.sink().len(),
first,
"the map was redrawn for nothing"
);
let empty = View::new(Tree::new("/scan"));
assert_eq!(screen.show(&empty, pane(), now).unwrap(), Drawn::Nothing);
}
#[test]
fn the_pane_gives_way_to_the_tree_rather_than_the_other_way_round() {
assert_eq!(Pane::width_in(MIN_WIDTH - 1), None);
let wide = Pane::width_in(200).unwrap();
assert!(wide <= 56, "{wide} columns of a 200-column terminal");
assert!(Pane::width_in(MIN_WIDTH).unwrap() <= MIN_WIDTH / 2);
}
#[test]
fn a_map_is_drawn_once_and_not_again_until_the_picture_changes() {
let mut screen = screen();
let mut view = view();
let now = Instant::now();
screen.show(&view, pane(), now).unwrap();
let first = screen.sink().len();
assert!(first > 1000, "nothing was drawn");
screen.show(&view, pane(), now).unwrap();
assert_eq!(
screen.sink().len(),
first,
"the map was redrawn for nothing"
);
view.apply(Action::Cursor(Motion::Down));
screen.show(&view, pane(), now).unwrap();
assert!(screen.sink().len() > first, "steering did not redraw");
}
#[test]
fn a_price_landing_waits_for_the_map_to_settle_and_the_cursor_never_does() {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/nx/node_modules", 8 * 1024 * 1024));
tree.insert(hit("/scan/pua/target", Size::Unmeasured, 0));
let mut view = View::new(tree);
view.allow_maps(Maps::Can);
view.sync();
let mut screen = screen();
let now = Instant::now();
screen.show(&view, pane(), now).unwrap();
let first = screen.sink().len();
view.priced(
std::path::Path::new("/scan/pua/target"),
Size::Measured(4096),
);
view.sync();
screen
.show(&view, pane(), now + Duration::from_millis(30))
.unwrap();
assert_eq!(screen.sink().len(), first, "an arrival redrew immediately");
screen.show(&view, pane(), now + SETTLE).unwrap();
assert!(screen.sink().len() > first, "the map never caught up");
}
#[test]
fn the_map_of_a_directory_that_has_been_deleted_comes_down_without_waiting_to_settle() {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/only/node_modules", 8 * 1024 * 1024));
let mut view = View::new(tree);
view.allow_maps(Maps::Can);
view.sync();
let mut screen = screen();
let now = Instant::now();
view.animate(now);
screen.show(&view, pane(), now).unwrap();
let before = screen.sink().len();
view.removed(
std::path::Path::new("/scan/only/node_modules"),
8 * 1024 * 1024,
true,
);
let later = now + crate::tui::moving::DIM;
view.animate(later);
assert!(crate::tui::moving::DIM < SETTLE);
screen.show(&view, pane(), later).unwrap();
let said = written(&screen);
assert!(
said.len() > before && said.ends_with("d=I,i=1976622,q=2\x1b\\"),
"the map outlived the directory it was of"
);
}
#[test]
fn an_arrival_outside_the_mapped_directory_is_not_a_redraw() {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/here/one/node_modules", 4 * 1024 * 1024));
tree.insert(priced("/scan/here/two/node_modules", 2 * 1024 * 1024));
tree.insert(priced("/scan/there/target", 1024));
let mut view = View::new(tree);
view.allow_maps(Maps::Can);
view.sync();
view.apply(Action::Cursor(Motion::Down));
let here = view
.tree()
.find(std::path::Path::new("/scan/here"))
.unwrap();
assert_eq!(tiles::focus(&view), Some(here));
let mut screen = screen();
let now = Instant::now();
screen.show(&view, pane(), now).unwrap();
let first = screen.sink().len();
view.found(priced("/scan/there/huge/node_modules", 64 * 1024 * 1024));
view.sync();
assert_eq!(
tiles::focus(&view),
Some(here),
"the map moved off its own directory"
);
screen.show(&view, pane(), now + SETTLE).unwrap();
assert_eq!(
screen.sink().len(),
first,
"a claim landing somewhere else redrew a picture that did not change"
);
}
#[test]
fn a_mark_is_the_readers_own_hand_rather_than_an_arrival_and_is_not_made_to_wait() {
let mut screen = screen();
let mut view = view();
let now = Instant::now();
view.apply(Action::Cursor(Motion::Down));
screen.show(&view, pane(), now).unwrap();
let before = screen.sink().len();
view.apply(Action::Mark);
screen.show(&view, pane(), now).unwrap();
assert!(
screen.sink().len() > before,
"a mark waited for a settle that is not for it"
);
}
#[test]
fn a_filter_redraws_the_map_it_narrows_at_once() {
let mut screen = screen();
let mut view = view();
let now = Instant::now();
screen.show(&view, pane(), now).unwrap();
let before = screen.sink().len();
view.apply(Action::OpenFilter);
for character in "target".chars() {
view.apply(Action::Type(character));
}
view.apply(Action::Submit);
screen.show(&view, pane(), now).unwrap();
assert!(
screen.sink().len() > before,
"the map went on showing what the filter took away"
);
}
#[test]
fn a_claim_the_view_hides_arriving_under_the_mapped_directory_is_not_a_redraw() {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/here/one/node_modules", 4 * 1024 * 1024));
tree.insert(priced("/scan/here/two/node_modules", 2 * 1024 * 1024));
let mut view = View::new(tree);
view.allow_maps(Maps::Can);
view.sync();
view.apply(Action::Cursor(Motion::Down));
let here = view
.tree()
.find(std::path::Path::new("/scan/here"))
.unwrap();
assert_eq!(tiles::focus(&view), Some(here));
let mut screen = screen();
let now = Instant::now();
screen.show(&view, pane(), now).unwrap();
let first = screen.sink().len();
let was = view.roll(here);
let mut unseen = gitignored("/scan/here/three/vendor");
unseen.size = Size::Measured(64 * 1024 * 1024);
view.found(unseen);
view.sync();
assert_eq!(
view.roll(here),
was,
"the fixture no longer makes the point — the claim has to be invisible"
);
screen.show(&view, pane(), now + SETTLE).unwrap();
assert_eq!(
screen.sink().len(),
first,
"a claim the view hides redrew a map that cannot draw it"
);
view.apply(Action::CycleTiers);
screen.show(&view, pane(), now + SETTLE).unwrap();
assert!(
screen.sink().len() > first,
"the map never caught up with the view widening"
);
}
#[test]
fn narrowing_the_view_by_kind_redraws_the_map_it_narrows() {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/a/node_modules", 8 * 1024 * 1024));
tree.insert(priced("/scan/b/target", 2 * 1024 * 1024));
let mut view = View::new(tree);
view.allow_maps(Maps::Can);
view.sync();
let mut screen = screen();
let now = Instant::now();
screen.show(&view, pane(), now).unwrap();
let before = screen.sink().len();
view.apply(Action::ToggleKind(crate::rules::Kind::Build));
screen.show(&view, pane(), now).unwrap();
assert!(
screen.sink().len() > before,
"the map went on drawing what the view stopped showing"
);
}
#[test]
fn a_claim_arriving_as_another_leaves_is_a_new_picture_even_though_the_totals_match() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/going/node_modules", Size::Unmeasured, 0));
tree.insert(hit("/scan/staying/target", Size::Unmeasured, 0));
let mut view = View::new(tree);
view.allow_maps(Maps::Can);
view.sync();
let mut screen = screen();
let now = Instant::now();
view.animate(now);
screen.show(&view, pane(), now).unwrap();
let before = screen.sink().len();
let totals = view.total();
view.removed(std::path::Path::new("/scan/going/node_modules"), 0, true);
let later = now + crate::tui::moving::DIM;
view.animate(later);
view.found(hit("/scan/arrived/node_modules", Size::Unmeasured, 0));
view.sync();
assert_eq!(
view.total(),
totals,
"the fixture no longer makes the point — the totals have to be identical"
);
screen.show(&view, pane(), later + SETTLE).unwrap();
assert!(
screen.sink().len() > before,
"the map is still drawing a directory that has been deleted"
);
}
#[test]
fn cycling_the_sort_is_not_a_new_picture() {
let mut screen = screen();
let mut view = view();
let now = Instant::now();
screen.show(&view, pane(), now).unwrap();
let before = screen.sink().len();
view.apply(Action::CycleSort);
view.sync();
screen.show(&view, pane(), now + SETTLE).unwrap();
assert_eq!(
screen.sink().len(),
before,
"re-sorting the tree spent a megabyte on the same picture"
);
}
struct Unflushable {
written: Vec<u8>,
refusing: Arc<AtomicBool>,
}
impl std::io::Write for Unflushable {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.written.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if self.refusing.load(Ordering::SeqCst) {
return Err(std::io::Error::other("the terminal stopped listening"));
}
Ok(())
}
}
#[test]
fn an_image_the_terminal_took_but_would_not_flush_is_still_taken_back() {
let refusing = Arc::new(AtomicBool::new(true));
let mut screen = Screen::new(
Unflushable {
written: Vec::new(),
refusing: Arc::clone(&refusing),
},
true,
);
let view = view();
assert!(
screen.show(&view, pane(), Instant::now()).is_err(),
"the flush was supposed to fail"
);
let sent = String::from_utf8_lossy(&screen.sink().written).into_owned();
assert!(
sent.contains("\x1b_Ga=T,"),
"the image never reached the terminal, so there is nothing to prove"
);
assert!(
screen.restore().is_err(),
"a refused delete was called done"
);
refusing.store(false, Ordering::SeqCst);
screen.restore().unwrap();
let said = String::from_utf8_lossy(&screen.sink().written).into_owned();
assert!(
said.ends_with("d=I,i=1976622,q=2\x1b\\"),
"the image was left in the terminal: {:?}",
&said[said.len() - 60..]
);
assert_eq!(said.matches("a=d,d=I").count(), 3, "unbalanced");
let settled = screen.sink().written.len();
screen.restore().unwrap();
assert_eq!(screen.sink().written.len(), settled, "deleted twice");
}
#[test]
fn the_image_is_taken_back_on_the_way_out_and_hiding_it_forgets_what_was_up() {
let mut screen = screen();
let view = view();
let now = Instant::now();
screen.show(&view, pane(), now).unwrap();
screen.restore().unwrap();
assert!(
written(&screen).ends_with("d=I,i=1976622,q=2\x1b\\"),
"left behind"
);
let after = screen.sink().len();
screen.restore().unwrap();
assert_eq!(screen.sink().len(), after, "taken back twice");
screen.show(&view, pane(), now).unwrap();
assert!(screen.sink().len() > after, "the map never came back");
}
#[test]
#[ignore = "a measurement rather than an assertion; timings are not a pass or a fail"]
fn measure_one_map() {
let mut tree = Tree::new("/home");
for repo in 0..300 {
for pkg in 0..20_u64 {
tree.insert(priced(
&format!("/home/repos/r{repo}/packages/p{pkg}/node_modules"),
4096 * (pkg + 1),
));
}
}
for n in 0..8_660 {
tree.insert(priced(&format!("/home/types/p{n}/node_modules"), 1024));
}
for n in 0..1_353 {
tree.insert(hit(
&format!("/home/cache/e{n}/target"),
Size::Unmeasured,
0,
));
}
let mut view = View::new(tree);
view.allow_maps(Maps::Can);
view.sync();
view.viewport(50);
view.allow_maps(Maps::Can);
view.sync();
println!("claims: {}", view.total().claims);
let pane = Pane {
cells: Rect::new(76, 2, 44, 34),
cell: (9, 19),
};
let (width, height) = pane.pixels().unwrap();
println!("pane: {width}×{height} px");
let started = Instant::now();
let root = tiles::focus(&view).unwrap();
let map = tiles::plan(&view, root, Area::of(f64::from(width), f64::from(height))).unwrap();
println!(
"plan: {:?} -> {} rectangles",
started.elapsed(),
map.tiles.len()
);
let started = Instant::now();
let canvas = paint::paint(&map, width, height);
println!(
"paint: {:?} -> {} px",
started.elapsed(),
canvas.rgb.len() / 3
);
let started = Instant::now();
let bytes = kitty::Image::shown(&canvas, (3, 77), (44, 34));
println!(
"encode: {:?} -> {} bytes down the pty",
started.elapsed(),
bytes.len()
);
let mut screen = screen();
let now = Instant::now();
screen.show(&view, pane, now).unwrap();
let started = Instant::now();
for _ in 0..100 {
screen.show(&view, pane, now).unwrap();
}
println!("100 still frames: {:?}", started.elapsed());
let started = Instant::now();
view.apply(Action::Cursor(Motion::Down));
screen.show(&view, pane, now).unwrap();
println!("one steer: {:?}", started.elapsed());
}
#[test]
fn a_view_with_nothing_in_it_takes_the_map_down_rather_than_drawing_an_empty_one() {
let mut screen = screen();
let view = view();
screen.show(&view, pane(), Instant::now()).unwrap();
let before = screen.sink().len();
let empty = View::new(Tree::new("/scan"));
assert_eq!(tiles::focus(&empty), Some(empty.tree().root()));
screen.show(&empty, pane(), Instant::now()).unwrap();
let said = written(&screen);
assert!(said.len() > before, "the map was left showing a stale tree");
assert!(
said.ends_with("d=I,i=1976622,q=2\x1b\\"),
"{}",
&said[said.len() - 40..]
);
}
}