use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::framebuffer::{self, Framebuffer};
use crate::geometry::CellSize;
use crate::render::kitty::{self, Placement};
use crate::source;
use crate::term::{self, RawTty, Terminal};
const TICK: Duration = Duration::from_millis(250);
const CONTINUATION: Duration = Duration::from_millis(50);
const CONTINUATION_SSH: Duration = Duration::from_millis(200);
const ZOOM_HEADROOM: u32 = 2;
const MAX_ZOOM: f64 = 32.0;
const MIN_ZOOM: f64 = 1.0;
const PAN_STEP: f64 = 0.2;
const ZOOM_IN: f64 = 1.25;
const ZOOM_OUT: f64 = 0.8;
pub fn run(
files: &[PathBuf],
terminal: Terminal,
background: [u8; 3],
) -> Result<(), Box<dyn std::error::Error>> {
let mut tty = RawTty::open().ok_or("cannot open /dev/tty")?;
let _screen = Screen::enter()?;
let mut out = BufWriter::new(std::io::stdout());
event_loop(&mut out, &mut tty, files, terminal, background)
}
struct Screen;
impl Screen {
fn enter() -> std::io::Result<Screen> {
let mut out = std::io::stdout();
out.write_all(b"\x1b[?1049h\x1b[?25l")?;
out.flush()?;
Ok(Screen)
}
}
impl Drop for Screen {
fn drop(&mut self) {
let mut out = std::io::stdout();
let _ = out.write_all(b"\x1b[?25h\x1b[?1049l");
let _ = out.flush();
}
}
struct Shown {
image: Framebuffer,
id: u32,
kind: source::Kind,
}
struct View {
zoom: f64,
cx: f64,
cy: f64,
}
impl View {
fn reset(shown: &Shown, cells: (u32, u32), cell: CellSize) -> View {
let mut view = View {
zoom: 1.0,
cx: shown.image.width() as f64 / 2.0,
cy: shown.image.height() as f64 / 2.0,
};
if shown.kind == source::Kind::Document {
view.cy = geom(shown, &view, cells, cell).src_h / 2.0;
}
view
}
}
const SPINNER: [char; 10] = ['\u{280b}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283c}',
'\u{2834}', '\u{2826}', '\u{2827}', '\u{2807}', '\u{280f}'];
const SPIN_TICK: Duration = Duration::from_millis(80);
const PATIENCE: Duration = Duration::from_millis(120);
struct Loading {
done: mpsc::Receiver<Result<Shown, String>>,
began: Instant,
}
enum Key {
Quit,
Pan(f64, f64),
Zoom(f64),
Reset,
Next,
Prev,
}
fn event_loop(
out: &mut impl Write,
tty: &mut RawTty,
files: &[PathBuf],
terminal: Terminal,
background: [u8; 3],
) -> Result<(), Box<dyn std::error::Error>> {
let cell = terminal.cell;
let continuation = if term::over_ssh() {
CONTINUATION_SSH
} else {
CONTINUATION
};
let mut cells = term::current_cells();
let mut index = 0usize;
let mut shown: Option<Shown> = None;
let mut failure: Option<String> = None;
let mut view = View {
zoom: 1.0,
cx: 0.0,
cy: 0.0,
};
let mut load_wanted = true;
let mut dirty = true;
let mut loading: Option<Loading> = None;
loop {
if load_wanted {
load_wanted = false;
failure = None;
loading = Some(spawn_load(files[index].clone(), cells, cell, background));
}
if let Some(job) = &loading {
match job.done.try_recv() {
Ok(result) => {
loading = None;
match result {
Ok(mut fresh) => {
view = View::reset(&fresh, cells, cell);
fresh.id = kitty::next_id();
let encoded = kitty::encode(&fresh.image)?;
kitty::emit(out, &encoded, fresh.id, false, kitty::Quiet::ErrorsOnly)?;
let previous = shown.replace(fresh);
draw(out, &shown, &view, cells, cell, files, index, &failure)?;
if let Some(old) = previous {
kitty::forget(out, old.id)?;
}
out.flush()?;
dirty = false;
}
Err(e) => {
failure = Some(e);
if let Some(old) = shown.take() {
kitty::forget(out, old.id)?;
}
dirty = true;
}
}
}
Err(mpsc::TryRecvError::Disconnected) => {
failure = Some("decode failed".into());
loading = None;
dirty = true;
}
Err(mpsc::TryRecvError::Empty) => {}
}
}
if dirty {
draw(out, &shown, &view, cells, cell, files, index, &failure)?;
out.flush()?;
dirty = false;
}
let wait = match &loading {
Some(job) if job.began.elapsed() >= PATIENCE => {
status_line(out, cells, &spinner_text(files, index, job.began))?;
out.flush()?;
SPIN_TICK
}
Some(_) => SPIN_TICK,
None => TICK,
};
let input = read_burst(tty, wait, continuation);
if input.is_empty() {
let now = term::current_cells();
if now != cells {
cells = now;
dirty = true;
}
continue;
}
if let Some(complaint) = graphics_error(&input) {
failure = Some(complaint);
dirty = true;
}
for key in keys_from(&input) {
match key {
Key::Quit => return Ok(()),
Key::Next if index + 1 < files.len() => {
index += 1;
load_wanted = true;
}
Key::Prev if index > 0 => {
index -= 1;
load_wanted = true;
}
Key::Next | Key::Prev => {}
Key::Reset => {
if let Some(s) = &shown {
view = View::reset(s, cells, cell);
}
dirty = true;
}
Key::Zoom(factor) => {
view.zoom = (view.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM);
dirty = true;
}
Key::Pan(dx, dy) => {
if let Some(s) = &shown {
let g = geom(s, &view, cells, cell);
view.cx += dx * g.src_w;
view.cy = (view.cy + dy * g.src_h)
.clamp(g.src_h / 2.0, (g.doc_h - g.src_h / 2.0).max(g.src_h / 2.0));
}
dirty = true;
}
}
}
}
}
fn placement(s: &Shown, view: &View, cells: (u32, u32), cell: CellSize) -> Placement {
let g = geom(s, view, cells, cell);
Placement {
src_x: (view.cx - g.src_w / 2.0).clamp(0.0, (g.img_w - g.src_w).max(0.0)) as u32,
src_y: g.doc_top as u32,
src_w: g.src_w as u32,
src_h: g.src_h as u32,
cols: ((g.shown_w / cell.w as f64).ceil() as u32).clamp(1, cells.0.max(1)),
rows: ((g.shown_h / cell.h as f64).ceil() as u32)
.clamp(1, cells.1.saturating_sub(1).max(1)),
}
}
struct Geom {
img_w: f64,
doc_h: f64,
shown_w: f64,
shown_h: f64,
src_w: f64,
src_h: f64,
doc_top: f64,
}
fn geom(s: &Shown, view: &View, cells: (u32, u32), cell: CellSize) -> Geom {
let img_w = s.image.width().max(1) as f64;
let doc_h = s.image.height().max(1) as f64;
let (cols, rows) = (cells.0.max(1), cells.1.saturating_sub(1).max(1));
let view_w = (cols * cell.w) as f64;
let view_h = (rows * cell.h) as f64;
let base = match s.kind {
source::Kind::Document => (view_w / img_w).min(1.0),
source::Kind::Image => (view_w / img_w).min(view_h / doc_h).min(1.0),
};
let scale = (base * view.zoom).max(f64::MIN_POSITIVE);
let shown_w = (img_w * scale).min(view_w);
let shown_h = (doc_h * scale).min(view_h);
let src_w = (shown_w / scale).round().clamp(1.0, img_w);
let src_h = (shown_h / scale).round().clamp(1.0, doc_h);
let doc_top = (view.cy - src_h / 2.0).clamp(0.0, (doc_h - src_h).max(0.0));
Geom {
img_w,
doc_h,
shown_w,
shown_h,
src_w,
src_h,
doc_top,
}
}
#[allow(clippy::too_many_arguments)]
fn draw(
out: &mut impl Write,
shown: &Option<Shown>,
view: &View,
cells: (u32, u32),
cell: CellSize,
files: &[PathBuf],
index: usize,
failure: &Option<String>,
) -> std::io::Result<()> {
out.write_all(b"\x1b[H\x1b[J")?;
if let Some(s) = shown {
kitty::place(out, s.id, &placement(s, view, cells, cell))?;
}
let name = files[index]
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let status = match failure {
Some(e) => format!("{name} {e}"),
None => format!(
"{name} [{}/{}] {:.0}% hjkl/arrows pan +/- zoom 0 reset n/p file q quit",
index + 1,
files.len(),
view.zoom * 100.0
),
};
status_line(out, cells, &status)
}
fn load(
path: &Path,
cells: (u32, u32),
cell: CellSize,
background: [u8; 3],
) -> Result<Shown, Box<dyn std::error::Error>> {
let bytes = std::fs::read(path)?;
let kind = source::kind(&bytes, path);
let headroom = match kind {
source::Kind::Document => 1,
source::Kind::Image => ZOOM_HEADROOM,
};
let hints = source::Hints {
max_w: cells.0 * cell.w * headroom,
max_h: match kind {
source::Kind::Document => u32::MAX,
source::Kind::Image => cells.1 * cell.h * headroom,
},
cell,
};
let decoded = source::load(&bytes, path, hints)?.fb;
let height_limit = match kind {
source::Kind::Document => f64::INFINITY,
source::Kind::Image => hints.max_h as f64,
};
let scale = (hints.max_w as f64 / decoded.width() as f64)
.min(height_limit / decoded.height() as f64)
.min(1.0);
let mut fb = if scale < 1.0 {
framebuffer::resize(
&decoded,
(decoded.width() as f64 * scale).round().max(1.0) as u32,
(decoded.height() as f64 * scale).round().max(1.0) as u32,
)
} else {
decoded
};
framebuffer::flatten_onto(&mut fb, background);
Ok(Shown {
image: fb,
id: 0,
kind,
})
}
fn spawn_load(
path: PathBuf,
cells: (u32, u32),
cell: CellSize,
background: [u8; 3],
) -> Loading {
let (tx, done) = mpsc::channel();
std::thread::spawn(move || {
let result = load(&path, cells, cell, background).map_err(|e| e.to_string());
let _ = tx.send(result);
});
Loading {
done,
began: Instant::now(),
}
}
fn spinner_frame(elapsed: Duration) -> char {
let step = (elapsed.as_millis() / SPIN_TICK.as_millis()) as usize;
SPINNER[step % SPINNER.len()]
}
fn spinner_text(files: &[PathBuf], index: usize, began: Instant) -> String {
let frame = spinner_frame(began.elapsed());
let name = files[index]
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
format!(
"{frame} rendering {name} [{}/{}] {:.1}s q quits",
index + 1,
files.len(),
began.elapsed().as_secs_f32(),
)
}
fn status_line(out: &mut impl Write, cells: (u32, u32), text: &str) -> std::io::Result<()> {
write!(out, "\x1b[{};1H\x1b[K", cells.1)?;
let width = cells.0 as usize;
out.write_all(text.chars().take(width).collect::<String>().as_bytes())
}
fn graphics_error(buf: &[u8]) -> Option<String> {
let mut i = 0;
while let Some(start) = find(&buf[i..], b"\x1b_G").map(|p| i + p) {
let Some(end) = find(&buf[start..], b"\x1b\\").map(|p| start + p) else {
break;
};
let body = &buf[start + 3..end];
if let Some(semi) = body.iter().position(|&c| c == b';') {
let message = String::from_utf8_lossy(&body[semi + 1..]);
if !message.is_empty() && message != "OK" {
return Some(format!("terminal refused the image: {message}"));
}
}
i = end + 2;
}
None
}
fn read_burst(tty: &mut RawTty, first: Duration, rest: Duration) -> Vec<u8> {
let mut buf = tty.read_available(first);
while !buf.is_empty() && decode_keys(&buf).1 < buf.len() {
let more = tty.read_available(rest);
if more.is_empty() {
break;
}
buf.extend_from_slice(&more);
}
buf
}
fn keys_from(buf: &[u8]) -> Vec<Key> {
let (mut keys, used) = decode_keys(buf);
if buf.len() - used == 1 && buf[used] == 0x1b {
keys.push(Key::Quit);
}
keys
}
fn decode_keys(buf: &[u8]) -> (Vec<Key>, usize) {
let mut keys = Vec::new();
let mut i = 0;
while i < buf.len() {
if buf[i..].starts_with(b"\x1b_") {
match find(&buf[i..], b"\x1b\\") {
Some(end) => i += end + 2,
None => break,
}
continue;
}
if buf[i..].starts_with(b"\x1b[") {
let Some(end) = buf[i + 2..]
.iter()
.position(|b| (0x40..=0x7e).contains(b))
.map(|p| i + 2 + p)
else {
break;
};
match buf[end] {
b'A' => keys.push(Key::Pan(0.0, -PAN_STEP)),
b'B' => keys.push(Key::Pan(0.0, PAN_STEP)),
b'C' => keys.push(Key::Pan(PAN_STEP, 0.0)),
b'D' => keys.push(Key::Pan(-PAN_STEP, 0.0)),
_ => {}
}
i = end + 1;
continue;
}
if buf[i] == 0x1b && i + 1 == buf.len() {
break;
}
match buf[i] {
b'q' | 0x1b | 0x03 => keys.push(Key::Quit),
b'h' => keys.push(Key::Pan(-PAN_STEP, 0.0)),
b'l' => keys.push(Key::Pan(PAN_STEP, 0.0)),
b'k' => keys.push(Key::Pan(0.0, -PAN_STEP)),
b'j' => keys.push(Key::Pan(0.0, PAN_STEP)),
b'+' | b'=' => keys.push(Key::Zoom(ZOOM_IN)),
b'-' | b'_' => keys.push(Key::Zoom(ZOOM_OUT)),
b'0' => keys.push(Key::Reset),
b'n' | b' ' => keys.push(Key::Next),
b'p' => keys.push(Key::Prev),
_ => {}
}
i += 1;
}
(keys, i)
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
#[cfg(test)]
mod tests {
use super::*;
const CELL: CellSize = CellSize { w: 10, h: 20 };
fn image(w: u32, h: u32) -> Framebuffer {
Framebuffer::new(w, h)
}
fn view(zoom: f64, cx: f64, cy: f64) -> View {
View { zoom, cx, cy }
}
#[test]
fn a_refused_image_is_reported_rather_than_skipped() {
let refusal = b"\x1b_Gi=31;EINVAL:image too large\x1b\\";
let got = graphics_error(refusal).expect("refusal was not noticed");
assert!(got.contains("EINVAL"), "{got}");
assert!(got.contains("too large"), "{got}");
}
#[test]
fn an_acknowledgement_is_not_an_error() {
assert_eq!(graphics_error(b"\x1b_Gi=31,I=1;OK\x1b\\"), None);
assert_eq!(graphics_error(b"hello"), None);
assert_eq!(graphics_error(b"\x1b_Gi=31;EINV"), None);
}
#[test]
fn a_refusal_is_found_even_mixed_in_with_typing() {
let mixed = b"j\x1b_Gi=7;ENOMEM\x1b\\k";
assert!(graphics_error(mixed).is_some());
assert_eq!(keys_from(mixed).len(), 2);
}
#[test]
fn the_spinner_cycles_and_never_leaves_the_frame_list() {
assert_eq!(spinner_frame(Duration::ZERO), SPINNER[0]);
assert_eq!(spinner_frame(SPIN_TICK), SPINNER[1]);
assert_eq!(spinner_frame(SPIN_TICK * SPINNER.len() as u32), SPINNER[0]);
assert_eq!(spinner_frame(Duration::from_secs(3600)), SPINNER[0]);
}
#[test]
fn nothing_is_said_about_a_wait_too_short_to_notice() {
assert!(PATIENCE >= SPIN_TICK);
assert!(PATIENCE < Duration::from_millis(500));
}
fn shown(image: Framebuffer, kind: source::Kind) -> Shown {
Shown { image, id: 1, kind }
}
#[test]
fn a_document_is_fitted_on_width_and_scrolls() {
let page = Framebuffer::new(900, 6000);
let v = view(1.0, 450.0, 200.0);
let doc = placement(&shown(page.clone(), source::Kind::Document), &v, (100, 30), CELL);
assert!(
doc.src_h < 6000,
"a document showed its whole height at rest, so there is no scroll"
);
let pic = placement(&shown(page.clone(), source::Kind::Image), &v, (100, 30), CELL);
assert_eq!(pic.src_h, 6000, "a picture should still be fitted whole");
assert!(
doc.src_h < pic.src_h,
"the document should show less at once than the fitted picture"
);
}
#[test]
fn a_document_opens_at_its_first_line() {
let page = Framebuffer::new(900, 6000);
let s = shown(page, source::Kind::Document);
let v = View::reset(&s, (100, 30), CELL);
let p = placement(&s, &v, (100, 30), CELL);
assert_eq!(p.src_y, 0, "document did not open at the top");
}
#[test]
fn unzoomed_shows_the_whole_image() {
let img = image(1000, 500);
let p = placement(&shown(img.clone(), source::Kind::Image), &view(1.0, 500.0, 250.0), (80, 25), CELL);
assert_eq!((p.src_x, p.src_y), (0, 0));
assert_eq!((p.src_w, p.src_h), (1000, 500));
}
#[test]
fn zooming_in_shrinks_the_source_rectangle() {
let img = image(1000, 500);
let wide = placement(&shown(img.clone(), source::Kind::Image), &view(1.0, 500.0, 250.0), (80, 25), CELL);
let close = placement(&shown(img.clone(), source::Kind::Image), &view(2.0, 500.0, 250.0), (80, 25), CELL);
assert!(close.src_w < wide.src_w && close.src_h < wide.src_h);
assert_eq!(close.src_w, wide.src_w / 2);
assert!(close.src_h > wide.src_h / 2);
}
#[test]
fn the_source_rectangle_keeps_the_display_box_aspect_ratio() {
let img = image(1000, 500);
for zoom in [1.0, 1.5, 2.0, 8.0] {
let p = placement(&shown(img.clone(), source::Kind::Image), &view(zoom, 500.0, 250.0), (80, 25), CELL);
let src = p.src_w as f64 / p.src_h as f64;
let dst = (p.cols * CELL.w) as f64 / (p.rows * CELL.h) as f64;
assert!(
(src - dst).abs() < 0.12,
"zoom {zoom}: source {src:.3} vs box {dst:.3}"
);
}
}
#[test]
fn the_cell_box_never_exceeds_the_viewport() {
let img = image(4000, 3000);
for zoom in [1.0, 2.0, 8.0, 32.0] {
let p = placement(&shown(img.clone(), source::Kind::Image), &view(zoom, 2000.0, 1500.0), (80, 25), CELL);
assert!(p.cols <= 80, "cols {} at zoom {zoom}", p.cols);
assert!(p.rows <= 24, "rows {} at zoom {zoom}", p.rows);
}
}
#[test]
fn panning_past_an_edge_clamps_inside_the_image() {
let img = image(1000, 500);
let p = placement(&shown(img.clone(), source::Kind::Image), &view(4.0, -9000.0, -9000.0), (80, 25), CELL);
assert_eq!((p.src_x, p.src_y), (0, 0));
let q = placement(&shown(img.clone(), source::Kind::Image), &view(4.0, 9000.0, 9000.0), (80, 25), CELL);
assert_eq!(q.src_x + q.src_w, 1000);
assert_eq!(q.src_y + q.src_h, 500);
}
#[test]
fn a_small_image_is_not_enlarged_at_rest() {
let img = image(40, 30);
let p = placement(&shown(img.clone(), source::Kind::Image), &view(1.0, 20.0, 15.0), (80, 25), CELL);
assert_eq!((p.src_w, p.src_h), (40, 30));
assert_eq!((p.cols, p.rows), (4, 2));
}
#[test]
fn graphics_acknowledgements_are_not_read_as_keys() {
let keys = keys_from(b"\x1b_Gi=31,I=1;OK\x1b\\");
assert!(keys.is_empty());
let mixed = keys_from(b"\x1b_Gi=31;OK\x1b\\q");
assert!(matches!(mixed.as_slice(), [Key::Quit]));
}
#[test]
fn arrow_keys_pan_and_modifiers_are_swallowed_whole() {
assert!(matches!(
keys_from(b"\x1b[A").as_slice(),
[Key::Pan(0.0, y)] if *y < 0.0
));
assert!(matches!(
keys_from(b"\x1b[1;5C").as_slice(),
[Key::Pan(x, 0.0)] if *x > 0.0
));
}
#[test]
fn a_lone_escape_quits() {
assert!(matches!(keys_from(b"\x1b").as_slice(), [Key::Quit]));
}
#[test]
fn an_arrow_key_split_by_the_network_is_not_a_quit() {
let (keys, used) = decode_keys(b"\x1b");
assert!(keys.is_empty());
assert_eq!(used, 0, "the escape has to survive for the next read");
assert!(matches!(
keys_from(b"\x1b[A").as_slice(),
[Key::Pan(0.0, y)] if *y < 0.0
));
}
#[test]
fn a_key_before_a_split_sequence_still_registers() {
let (keys, used) = decode_keys(b"n\x1b[");
assert!(matches!(keys.as_slice(), [Key::Next]));
assert_eq!(used, 1);
}
}