use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver};
use cellix_doc::pdf::{Document, Page};
use cellix_doc::synctex::Map;
use cellix_render::{Bitmap, Bounds, Grid, Paint, Pixels, Rect, Style};
use crate::theme::Theme;
const GAP: f32 = 0.6;
const SIDE: f32 = 1.0;
const ZOOM_STEP: f32 = 0.25;
const ZOOM_RANGE: (f32, f32) = (0.5, 3.0);
pub const SIDEWAYS: f32 = 6.0;
const THUMB: f32 = 0.5;
const THUMB_INSET: f32 = 0.2;
const BOTTOM_GAP: f32 = 0.25;
const END_GAP: f32 = 0.6;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Hit {
ZoomOut,
ZoomIn,
Fit,
Sideways,
Upright,
Page(usize, f32, f32),
}
enum Update {
Sizes(Vec<(f32, f32)>),
Done(usize, Page),
Failed(String),
}
pub struct PdfView {
pub path: PathBuf,
pages: Vec<Option<(Bitmap, f32, f32)>>,
sizes: Vec<(f32, f32)>,
zoom: f32,
sync: Option<Map>,
updates: Option<Receiver<Update>>,
fit: (u16, (u32, u32)),
rows: u16,
pub top: f32,
pub left: f32,
pub error: Option<String>,
generation: u64,
seen: Option<std::time::SystemTime>,
}
fn modified(path: &Path) -> Option<std::time::SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
fn image_page(path: &Path, width: u32) -> Result<Page, String> {
let image = image::open(path).map_err(|e| e.to_string())?.to_rgba8();
let image = if image.width() > width {
let height = image.height() * width / image.width();
image::imageops::thumbnail(&image, width, height.max(1))
} else {
image
};
let (width, height) = image.dimensions();
Ok(Page {
width,
height,
rgba: std::sync::Arc::new(image.into_raw()),
})
}
fn buttons(area: Rect) -> (u16, u16, u16) {
let fit = area.right().saturating_sub(5);
(fit.saturating_sub(10), fit.saturating_sub(3), fit)
}
impl PdfView {
pub fn new(path: &Path) -> Self {
Self {
path: path.to_path_buf(),
pages: Vec::new(),
sizes: Vec::new(),
zoom: 1.0,
sync: None,
updates: None,
fit: (0, (0, 0)),
rows: 0,
top: 0.0,
left: 0.0,
error: None,
generation: 0,
seen: None,
}
}
pub fn is_image(&self) -> bool {
!self.path.extension().is_some_and(|e| e == "pdf")
}
fn noun(&self) -> &'static str {
if self.is_image() { "image" } else { "PDF" }
}
pub fn load(&mut self, width: u16, cell: (u32, u32)) {
let (sender, updates) = mpsc::channel();
let path = self.path.clone();
self.fit = (width, cell);
self.generation += 1;
self.updates = Some(updates);
self.error = None;
let pixels = (width as f32 - SIDE * 2.0).max(1.0) * self.zoom;
let pixels = pixels as u32 * cell.0;
self.seen = modified(&self.path);
self.sync = Map::beside(&self.path);
std::thread::spawn(move || {
if !path.extension().is_some_and(|e| e == "pdf") {
let _ = match image_page(&path, pixels) {
Ok(page) => sender
.send(Update::Sizes(vec![(page.width as f32, page.height as f32)]))
.and_then(|()| sender.send(Update::Done(0, page))),
Err(e) => sender.send(Update::Failed(e)),
};
return;
}
let opened = std::fs::read(&path)
.map_err(|e| e.to_string())
.and_then(Document::open);
let document = match opened {
Ok(document) => document,
Err(e) => {
let _ = sender.send(Update::Failed(e));
return;
}
};
let sizes = (0..document.pages()).filter_map(|p| document.size(p));
let _ = sender.send(Update::Sizes(sizes.collect()));
for index in 0..document.pages() {
let Some(page) = document.render(index, pixels) else {
continue;
};
if sender.send(Update::Done(index, page)).is_err() {
return;
}
}
});
}
pub fn refresh(&mut self) {
let (width, cell) = self.fit;
if width > 0 && modified(&self.path) != self.seen {
self.load(width, cell);
}
}
pub fn fit(&mut self, (width, height): (u16, u16), cell: (u32, u32)) {
self.rows = height.saturating_sub(1);
if (width, cell) != self.fit {
self.load(width, cell);
}
}
pub fn poll(&mut self) -> bool {
let Some(updates) = &self.updates else {
return false;
};
let cell = self.fit.1;
let mut changed = false;
while let Ok(update) = updates.try_recv() {
changed = true;
match update {
Update::Sizes(sizes) => {
self.pages.resize(sizes.len(), None);
self.sizes = sizes;
}
Update::Failed(e) => self.error = Some(e),
Update::Done(index, page) => {
let bitmap = Bitmap {
id: self.generation << 16 | index as u64,
width: page.width,
height: page.height,
kind: Pixels::Rgba,
pixels: page.rgba.clone(),
};
let width = page.width as f32 / cell.0 as f32;
let height = page.height as f32 / cell.1 as f32;
if let Some(slot) = self.pages.get_mut(index) {
*slot = Some((bitmap, width, height));
}
}
}
}
if changed {
self.pan(0.0, 0.0);
}
changed
}
#[cfg(test)]
pub fn pages_ready(&self) -> usize {
self.pages.iter().flatten().count()
}
pub fn zoom(&mut self, hit: Hit) {
let before = self.zoom;
self.zoom = match hit {
Hit::ZoomIn => self.zoom + ZOOM_STEP,
Hit::ZoomOut => self.zoom - ZOOM_STEP,
_ => 1.0,
}
.clamp(ZOOM_RANGE.0, ZOOM_RANGE.1);
let grown = self.zoom / before;
let (width, content) = (f32::from(self.fit.0), self.content_width());
let middle = if self.wide(self.fit.0) {
(self.left + width / 2.0) / content
} else {
0.5
};
self.left = (middle * content * grown - width / 2.0).max(0.0);
self.top *= grown;
let (width, cell) = self.fit;
self.load(width, cell);
}
pub fn source_at(&self, page: usize, x: f32, y: f32) -> Option<(PathBuf, usize)> {
self.sync.as_ref()?.to_source(page, x, y)
}
pub fn show_source(&mut self, file: &Path, line: usize) -> bool {
let Some((page, y)) = self.sync.as_ref().and_then(|map| map.to_pdf(file, line)) else {
return false;
};
let above: f32 = self.pages[..page.min(self.pages.len())]
.iter()
.flatten()
.map(|(_, _, height)| height + GAP)
.sum();
let within = match (self.pages.get(page), self.sizes.get(page)) {
(Some(Some((_, _, height))), Some((_, points))) => y / points * height,
_ => 0.0,
};
self.top = (above + within - 6.0).max(0.0);
true
}
fn page_in_view(&self) -> usize {
let mut y = 0.0;
for (index, page) in self.pages.iter().flatten().enumerate() {
y += page.2 + GAP;
if y > self.top + 1.0 {
return index + 1;
}
}
self.pages.len().max(1)
}
fn content_width(&self) -> f32 {
let widest = self.pages.iter().flatten().map(|p| p.1).fold(0.0, f32::max);
widest + SIDE * 2.0
}
fn content_height(&self) -> f32 {
let pages = self.pages.iter().flatten();
pages.map(|(_, _, height)| height + GAP).sum::<f32>() + GAP
}
pub fn wide(&self, width: u16) -> bool {
self.content_width() > f32::from(width) + 0.01
}
fn tall(&self, rows: u16) -> bool {
self.content_height() > f32::from(rows)
}
fn page_x(&self, area: Rect, width: f32) -> f32 {
let room = f32::from(area.w);
if !self.wide(area.w) {
return f32::from(area.x) + (room - width) / 2.0;
}
f32::from(area.x) + (self.content_width() - width) / 2.0 - self.left
}
pub fn pan(&mut self, columns: f32, rows: f32) {
let most = (self.content_width() - f32::from(self.fit.0)).max(0.0);
self.left = (self.left + columns).clamp(0.0, most);
self.scroll(rows);
}
pub fn place(&mut self, left: f32, top: f32) {
self.left = left;
self.top = top;
self.pan(0.0, 0.0);
}
pub fn follow_bar(&mut self, bar: Hit, area: Rect, x: f32, y: u16) {
let view = Rect::new(area.x, area.y + 1, area.w, area.h.saturating_sub(1));
if bar == Hit::Sideways {
let along = (x - f32::from(view.x)) / f32::from(view.w.max(1));
let content = self.content_width();
self.left = along * content - f32::from(view.w) / 2.0;
} else {
let along = (f32::from(y) + 0.5 - f32::from(view.y)) / f32::from(view.h.max(1));
self.top = along * self.content_height() - f32::from(view.h) / 2.0;
}
self.pan(0.0, 0.0);
}
pub fn hit(&self, area: Rect, x: u16, y: u16) -> Option<Hit> {
if y == area.y {
let (minus, plus, fit) = buttons(area);
return if x == minus {
Some(Hit::ZoomOut)
} else if x == plus {
Some(Hit::ZoomIn)
} else if x >= fit && x < fit + 3 {
Some(Hit::Fit)
} else {
None
};
}
if self.wide(area.w) && y + 1 == area.bottom() {
return Some(Hit::Sideways);
}
if self.tall(area.h.saturating_sub(1)) && x + 1 == area.right() && y > area.y {
return Some(Hit::Upright);
}
let mut top = area.y as f32 + 1.0 + GAP - self.top;
for (index, page) in self.pages.iter().enumerate() {
let Some((_, width, height)) = page else {
continue;
};
if (y as f32) >= top && (y as f32) < top + height {
let left = self.page_x(area, *width);
let (points_x, points_y) = self.sizes.get(index).copied()?;
let across = (x as f32 + 0.5 - left).max(0.0) / width * points_x;
let down = (y as f32 + 0.5 - top) / height * points_y;
return Some(Hit::Page(index, across, down));
}
top += height + GAP;
}
None
}
pub fn scroll(&mut self, rows: f32) {
let most = if self.rows > 0 {
self.content_height() - f32::from(self.rows)
} else {
self.content_height() - 4.0
};
self.top = (self.top + rows).clamp(0.0, most.max(0.0));
}
pub fn draw(&self, grid: &mut Grid, area: Rect, cell_tier: bool, theme: &Theme) {
grid.fill(area, theme.sidebar);
let quiet = Style {
fg: theme.gutter.fg,
..theme.sidebar
};
let left = area.x + 2;
if let Some(error) = &self.error {
let said = format!("This {} can't be shown.", self.noun());
grid.label(left, area.y + 1, &said, theme.sidebar, area.right());
grid.label(left, area.y + 2, error, quiet, area.right());
return;
}
if cell_tier {
let name = self.path.display().to_string();
let said = if self.is_image() {
"Image ready:"
} else {
"PDF ready:"
};
grid.label(left, area.y + 1, said, theme.sidebar, area.right());
grid.label(left, area.y + 2, &name, quiet, area.right());
return;
}
if self.pages.is_empty() {
let said = format!("Reading the {}…", self.noun());
grid.label(left, area.y + 1, &said, quiet, area.right());
return;
}
let bar = Rect::new(area.x, area.y, area.w, 1);
grid.fill(bar, theme.tab);
let (minus, plus, fit) = buttons(area);
let quiet_bar = Style {
fg: theme.status.fg,
..theme.tab
};
let count = if self.is_image() {
let name = self.path.file_name().unwrap_or_default();
name.to_string_lossy().into_owned()
} else {
format!("Page {} of {}", self.page_in_view(), self.pages.len())
};
let under_tab = area.x as f32 + 1.5;
let end = minus.saturating_sub(1);
grid.label_at(under_tab, left, area.y, &count, quiet_bar, end, 100);
grid.label(minus, area.y, "−", quiet_bar, minus + 1);
let size = format!("{:.0}%", self.zoom * 100.0);
grid.label(minus + 2, area.y, &size, quiet_bar, plus);
grid.label(plus, area.y, "+", quiet_bar, plus + 1);
grid.label(fit, area.y, "Fit", quiet_bar, area.right());
let area = Rect::new(area.x, area.y + 1, area.w, area.h.saturating_sub(1));
let clip = Bounds::cells(
area.x as f32,
area.y as f32 + GAP,
area.w as f32,
area.h as f32 - GAP,
);
let mut y = area.y as f32 + GAP - self.top;
for page in self.pages.iter().flatten() {
let (bitmap, width, height) = page;
if y + height > area.y as f32 && y < area.bottom() as f32 {
let x = self.page_x(area, *width);
let bounds = Bounds::cells(x, y, *width, *height);
grid.paint.push(Paint::Image {
bounds,
clip,
bitmap: bitmap.clone(),
color: theme.text.fg,
});
draw_edge(grid, (x, y, *width, *height), area, theme);
}
y += height + GAP;
}
self.draw_thumbs(grid, area, theme);
}
fn draw_thumbs(&self, grid: &mut Grid, area: Rect, theme: &Theme) {
let (cw, ch) = self.fit.1;
let aspect = if ch > 0 { cw as f32 / ch as f32 } else { 0.5 };
let (x, y) = (f32::from(area.x), f32::from(area.y));
let (w, h) = (f32::from(area.w), f32::from(area.h));
if self.wide(area.w) {
let content = self.content_width();
let thick = THUMB * aspect;
let track = w - 2.0 * END_GAP;
let long = (track * w / content).max(2.0);
let start = x + END_GAP + self.left / content * track;
let top = y + h - BOTTOM_GAP - thick;
let bounds = Bounds::cells(start, top, long, thick);
grid.panel(bounds, (thick * 50.0) as u8, Some(theme.scrollbar), None);
}
if self.tall(area.h) && area.h > 0 {
let content = self.content_height();
let track = h - 2.0 * END_GAP * aspect;
let long = (track * h / content).max(1.0);
let start = y + END_GAP * aspect + (self.top / content * track).min(track - long);
let left = x + w - THUMB_INSET - THUMB;
let bounds = Bounds::cells(left, start, THUMB, long);
grid.panel(
bounds,
(THUMB * aspect * 50.0) as u8,
Some(theme.scrollbar),
None,
);
}
}
}
fn draw_edge(grid: &mut Grid, (x, y, w, h): (f32, f32, f32, f32), area: Rect, theme: &Theme) {
let (left, top) = (f32::from(area.x), f32::from(area.y));
let (right, bottom) = (f32::from(area.right()), f32::from(area.bottom()));
let (x0, x1) = (x.max(left), (x + w).min(right));
let (y0, y1) = (y.max(top), (y + h).min(bottom));
if x1 <= x0 || y1 <= y0 {
return;
}
let colour = theme.border;
if y >= top {
grid.hairline(Bounds::cells(x0, y, x1 - x0, 0.0), colour);
}
if y + h <= bottom {
grid.hairline(Bounds::cells(x0, y + h, x1 - x0, 0.0), colour);
}
if x >= left {
grid.hairline(Bounds::cells(x, y0, 0.0, y1 - y0), colour);
}
if x + w <= right {
grid.hairline(Bounds::cells(x + w, y0, 0.0, y1 - y0), colour);
}
}