use std::io::IsTerminal as _;
use std::io::Write as _;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use pdfboss_aio::{AsyncDocument, Backend as _, CachedBackend, HttpBackend};
const BAR_WIDTH: usize = 30;
const MAP_WIDTH: usize = 40;
fn human_size(bytes: u64) -> String {
const UNITS: [(&str, u64); 3] = [
("GiB", 1024 * 1024 * 1024),
("MiB", 1024 * 1024),
("KiB", 1024),
];
for (unit, scale) in UNITS {
if bytes >= scale {
return format!("{:.1} {unit}", bytes as f64 / scale as f64);
}
}
format!("{bytes} B")
}
fn percent_of(collected: u64, total: u64) -> u64 {
(collected.min(total) * 100)
.checked_div(total)
.unwrap_or(100)
}
fn progress_line(collected: u64, total: u64) -> String {
let collected = collected.min(total);
let percent = percent_of(collected, total);
let filled = (percent as usize * BAR_WIDTH) / 100;
let bar = if filled == BAR_WIDTH {
"=".repeat(BAR_WIDTH)
} else {
format!(
"{}>{}",
"=".repeat(filled),
" ".repeat(BAR_WIDTH - filled - 1)
)
};
format!(
"[{bar}] {percent:>3}% {}/{}",
human_size(collected),
human_size(total)
)
}
fn cell_of(offset: u64, total: u64, width: usize) -> usize {
if total == 0 {
return 0;
}
(offset.min(total - 1) * width as u64 / total) as usize
}
fn mark_fetch(cells: &mut [bool], offset: u64, len: u64, total: u64) {
if len == 0 || total == 0 {
return;
}
let first = cell_of(offset, total, cells.len());
let last = cell_of(offset + len - 1, total, cells.len());
for cell in &mut cells[first..=last] {
*cell = true;
}
}
fn caret_line(cell: usize) -> String {
format!("{}▼", " ".repeat(2 + cell))
}
fn map_line(cells: &[bool], fetched: u64, total: u64) -> String {
let map: String = cells.iter().map(|&on| if on { '█' } else { '░' }).collect();
format!(" {map} {} / {}", human_size(fetched), human_size(total))
}
struct OpenMinimap {
total: u64,
state: Mutex<MinimapState>,
}
struct MinimapState {
cells: Vec<bool>,
fetched: u64,
started: bool,
finished: bool,
}
impl OpenMinimap {
fn new(total: u64) -> OpenMinimap {
OpenMinimap {
total,
state: Mutex::new(MinimapState {
cells: vec![false; MAP_WIDTH],
fetched: 0,
started: false,
finished: false,
}),
}
}
fn frame(&self, offset: u64, len: u64) -> Option<String> {
let mut state = self.state.lock().expect("minimap mutex");
if state.finished {
return None;
}
mark_fetch(&mut state.cells, offset, len, self.total);
state.fetched += len;
let caret = caret_line(cell_of(offset, self.total, MAP_WIDTH));
let map = map_line(&state.cells, state.fetched, self.total);
if state.started {
return Some(format!("\r\x1b[1A\x1b[K{caret}\n\x1b[K{map}"));
}
state.started = true;
Some(format!("{caret}\n{map}"))
}
fn tick(&self, offset: u64, len: u64) {
let Some(frame) = self.frame(offset, len) else {
return;
};
eprint!("{frame}");
let _ = std::io::stderr().flush();
}
fn finish(&self) {
let mut state = self.state.lock().expect("minimap mutex");
let erase = state.started && !state.finished;
state.finished = true;
if !erase {
return;
}
eprint!("\r\x1b[K\x1b[1A\x1b[K");
let _ = std::io::stderr().flush();
}
}
struct DownloadBar {
started: AtomicBool,
last_percent: AtomicU64,
}
impl DownloadBar {
fn new() -> DownloadBar {
DownloadBar {
started: AtomicBool::new(false),
last_percent: AtomicU64::new(u64::MAX),
}
}
fn tick(&self, collected: u64, total: u64) {
if !self.started.swap(true, Ordering::SeqCst) {
eprintln!(
"pdfboss: server ignores Range requests, downloading the whole file ({})",
human_size(total)
);
}
let percent = percent_of(collected, total);
if self.last_percent.swap(percent, Ordering::SeqCst) == percent {
return;
}
eprint!("\r{}", progress_line(collected, total));
let _ = std::io::stderr().flush();
}
fn finish(&self) {
if !self.started.load(Ordering::SeqCst) {
return;
}
eprint!("\r\x1b[K");
let _ = std::io::stderr().flush();
}
fn is_started(&self) -> bool {
self.started.load(Ordering::SeqCst)
}
}
pub async fn open_url_with_progress(
url: &str,
password: &str,
) -> pdfboss_aio::Result<AsyncDocument> {
let backend = HttpBackend::new(url).await?;
if !std::io::stderr().is_terminal() {
return AsyncDocument::with_backend_with_password(CachedBackend::new(backend), password)
.await;
}
let total = backend.len().await.map_err(pdfboss_aio::Error::from)?;
let bar = Arc::new(DownloadBar::new());
let ticker = Arc::clone(&bar);
let backend =
backend.on_fallback_progress(move |collected, total| ticker.tick(collected, total));
let map = Arc::new(OpenMinimap::new(total));
let painter = Arc::clone(&map);
let fallback = Arc::clone(&bar);
let cached = CachedBackend::new(backend).on_fetch(move |offset, len| {
if fallback.is_started() {
return;
}
painter.tick(offset, len);
});
let doc = AsyncDocument::with_backend_with_password(cached, password).await;
map.finish();
bar.finish();
doc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn human_size_picks_the_unit() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(1023), "1023 B");
assert_eq!(human_size(1024), "1.0 KiB");
assert_eq!(human_size(110_175), "107.6 KiB");
assert_eq!(human_size(11_165_345), "10.6 MiB");
assert_eq!(human_size(3 * 1024 * 1024 * 1024), "3.0 GiB");
}
#[test]
fn progress_line_renders_the_bar_states() {
assert_eq!(
progress_line(0, 110_175),
"[> ] 0% 0 B/107.6 KiB"
);
assert_eq!(
progress_line(55_000, 110_175),
"[==============> ] 49% 53.7 KiB/107.6 KiB"
);
assert_eq!(
progress_line(110_175, 110_175),
"[==============================] 100% 107.6 KiB/107.6 KiB"
);
}
#[test]
fn progress_line_clamps_overrun_and_zero_total() {
assert_eq!(
progress_line(2000, 1000),
"[==============================] 100% 1000 B/1000 B"
);
assert!(progress_line(0, 0).contains("100%"));
}
#[test]
fn cell_of_scales_offsets_into_the_map() {
assert_eq!(cell_of(0, 1000, 10), 0);
assert_eq!(cell_of(500, 1000, 10), 5);
assert_eq!(cell_of(999, 1000, 10), 9);
assert_eq!(cell_of(2000, 1000, 10), 9);
assert_eq!(cell_of(0, 0, 10), 0);
}
#[test]
fn mark_fetch_covers_the_scaled_byte_range() {
let mut cells = vec![false; 10];
mark_fetch(&mut cells, 100, 200, 1000);
assert_eq!(map_line(&cells, 200, 1000), " ░██░░░░░░░ 200 B / 1000 B");
mark_fetch(&mut cells, 950, 50, 1000);
assert_eq!(map_line(&cells, 250, 1000), " ░██░░░░░░█ 250 B / 1000 B");
mark_fetch(&mut cells, 0, 0, 1000);
assert_eq!(map_line(&cells, 250, 1000), " ░██░░░░░░█ 250 B / 1000 B");
}
#[test]
fn minimap_frames_draw_then_redraw_then_stop_after_finish() {
let map = OpenMinimap::new(1000);
let first = map.frame(0, 100).expect("first frame draws");
assert!(
!first.contains("\x1b[1A"),
"first frame must not move the cursor up: {first:?}"
);
let second = map.frame(500, 100).expect("second frame redraws");
assert!(
second.starts_with("\r\x1b[1A"),
"redraw must reclaim its two lines: {second:?}"
);
map.finish();
assert_eq!(
map.frame(900, 50),
None,
"a finished minimap must never draw again"
);
}
#[test]
fn caret_line_points_at_the_current_fetch() {
assert_eq!(caret_line(0), " ▼");
assert_eq!(caret_line(3), " ▼");
}
}