document and image viewer
git clone https://git.lucas.co/cce-preview.git
cce-preview: image & PDF viewer (macOS Preview-style)
Continuous vertically-scrolled document view built on cce-ui. PDFs are
rasterized lazily per page via poppler's pdftoppm (page sizes from
pdfinfo), with DPI-bucketed re-renders on zoom and an LRU GPU page
cache; raster images are single-page documents with directory
browsing. Zoom-at-pointer (ctrl+wheel/pinch/keys), fit and actual-size
modes, whole-document rotation, drag pan, live title with page count.
Co-Authored-By: Claude Fable 5 <[email protected]>
.gitignore | 2 +
Cargo.toml | 14 ++
src/doc.rs | 308 ++++++++++++++++++++++++++++++++++++
src/main.rs | 518 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 842 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..1f67ff1
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "cce-preview"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+image = { version = "0.25", default-features = false, features = [
+ "png", "jpeg", "gif", "webp", "bmp", "tiff", "ico",
+] }
+log = "0.4"
+env_logger = "0.11"
diff --git a/src/doc.rs b/src/doc.rs
new file mode 100644
index 0000000..0c09e78
--- /dev/null
+++ b/src/doc.rs
@@ -0,0 +1,308 @@
+//! Document model + background page rasterization for cce-preview.
+//!
+//! A document is a list of pages with known sizes: PDFs report points via
+//! `pdfinfo` and rasterize per page through `pdftoppm` (poppler), raster
+//! images are single-page documents sized in pixels. Workers decode or
+//! rasterize off-thread, upload RGBA via `cce_ui::vk::upload_rgba` (the
+//! upload queue is thread-safe), then notify the app over the calloop
+//! channel so the engine wakes and repaints.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::{mpsc, Arc, Mutex};
+
+use crate::Message;
+
+/// Extensions the `image` crate is built to decode (keep in sync with the
+/// feature list in Cargo.toml).
+pub const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "ico"];
+
+/// GPU pages kept resident. The cce-ui image registry hard-caps at 256
+/// images total, so leave generous headroom.
+const MAX_GPU_PAGES: usize = 24;
+/// Largest bitmap edge we'll upload; bigger sources are downscaled (images)
+/// or rendered at a capped DPI (PDF pages).
+const MAX_DIM: u32 = 8192;
+const RENDER_THREADS: usize = 2;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Kind {
+ Image,
+ Pdf,
+}
+
+/// Page size in document units: points for PDFs, pixels for images.
+#[derive(Debug, Clone, Copy)]
+pub struct PageSize {
+ pub w: f64,
+ pub h: f64,
+}
+
+pub struct Document {
+ pub path: PathBuf,
+ pub kind: Kind,
+ pub pages: Vec<PageSize>,
+}
+
+impl Document {
+ pub fn load(path: &Path) -> Result<Self, String> {
+ let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
+ match ext.as_deref() {
+ Some("pdf") => Self::load_pdf(path),
+ Some(e) if IMAGE_EXTS.contains(&e) => Self::load_image(path),
+ _ => Err("unsupported file type".to_string()),
+ }
+ }
+
+ fn load_image(path: &Path) -> Result<Self, String> {
+ let (w, h) = image::image_dimensions(path).map_err(|e| e.to_string())?;
+ Ok(Self {
+ path: path.to_path_buf(),
+ kind: Kind::Image,
+ pages: vec![PageSize { w: w as f64, h: h as f64 }],
+ })
+ }
+
+ /// Page count from `pdfinfo`, then per-page sizes from a second ranged
+ /// call. `pdfinfo` reports MediaBox dimensions with a separate `rot`
+ /// field, while `pdftoppm` bakes /Rotate into its output — so swap
+ /// width/height here for 90°/270° pages to keep layout and pixels agreed.
+ fn load_pdf(path: &Path) -> Result<Self, String> {
+ let count_out = pdfinfo(path, &[])?;
+ let count: usize = count_out
+ .lines()
+ .find_map(|l| l.strip_prefix("Pages:"))
+ .and_then(|v| v.trim().parse().ok())
+ .ok_or("pdfinfo: no page count")?;
+ if count == 0 {
+ return Err("empty PDF".to_string());
+ }
+ let sizes_out = pdfinfo(path, &["-f", "1", "-l", &count.to_string()])?;
+ let mut sizes: Vec<PageSize> = Vec::with_capacity(count);
+ let mut rots: Vec<i32> = Vec::with_capacity(count);
+ for line in sizes_out.lines() {
+ let Some(rest) = line.strip_prefix("Page ") else { continue };
+ let Some((_, field)) = rest.trim_start().split_once(' ') else { continue };
+ if let Some(v) = field.trim_start().strip_prefix("size:") {
+ // "595.276 x 841.89 pts (A4)"
+ let mut it = v.trim().split_whitespace();
+ let w: f64 = it.next().and_then(|s| s.parse().ok()).ok_or("pdfinfo: bad size")?;
+ let h: f64 = it.nth(1).and_then(|s| s.parse().ok()).ok_or("pdfinfo: bad size")?;
+ sizes.push(PageSize { w, h });
+ } else if let Some(v) = field.trim_start().strip_prefix("rot:") {
+ rots.push(v.trim().parse().unwrap_or(0));
+ }
+ }
+ if sizes.len() != count {
+ return Err(format!("pdfinfo: {} sizes for {count} pages", sizes.len()));
+ }
+ for (s, rot) in sizes.iter_mut().zip(rots) {
+ if rot == 90 || rot == 270 {
+ std::mem::swap(&mut s.w, &mut s.h);
+ }
+ }
+ Ok(Self { path: path.to_path_buf(), kind: Kind::Pdf, pages: sizes })
+ }
+}
+
+fn pdfinfo(path: &Path, args: &[&str]) -> Result<String, String> {
+ let out = Command::new("pdfinfo")
+ .args(args)
+ .arg(path)
+ .output()
+ .map_err(|e| format!("pdfinfo: {e}"))?;
+ if !out.status.success() {
+ return Err(format!("pdfinfo: {}", String::from_utf8_lossy(&out.stderr).trim()));
+ }
+ Ok(String::from_utf8_lossy(&out.stdout).into_owned())
+}
+
+/// A rendered page as delivered by a worker.
+#[derive(Debug, Clone, Copy)]
+pub struct Rendered {
+ pub image: u32,
+ pub dpi: u32,
+}
+
+struct Job {
+ generation: u64,
+ page: usize,
+ dpi: u32,
+ path: PathBuf,
+ kind: Kind,
+ size: PageSize,
+ /// Extra user rotation in quarter turns cw, applied to the pixels.
+ quarter_turns: u8,
+}
+
+enum PageState {
+ Pending,
+ Ready { r: Rendered, refreshing: bool, last_used: u64 },
+ Failed,
+}
+
+/// Per-document GPU page cache: lazy render requests, DPI upgrades, LRU
+/// eviction. `reset()` bumps the generation so late results from a previous
+/// document/rotation are freed on arrival instead of displayed.
+pub struct PageStore {
+ states: HashMap<usize, PageState>,
+ queue: mpsc::Sender<Job>,
+ generation: u64,
+ frame: u64,
+}
+
+impl PageStore {
+ pub fn new(notify: calloop::channel::Sender<Message>) -> Self {
+ let (queue, rx) = mpsc::channel::<Job>();
+ let rx = Arc::new(Mutex::new(rx));
+ for _ in 0..RENDER_THREADS {
+ let rx = Arc::clone(&rx);
+ let notify = notify.clone();
+ std::thread::spawn(move || worker(rx, notify));
+ }
+ Self { states: HashMap::new(), queue, generation: 0, frame: 0 }
+ }
+
+ pub fn begin_frame(&mut self) {
+ self.frame += 1;
+ }
+
+ pub fn reset(&mut self) {
+ for (_, state) in self.states.drain() {
+ if let PageState::Ready { r, .. } = state {
+ cce_ui::vk::free_image(r.image);
+ }
+ }
+ self.generation += 1;
+ }
+
+ /// The page's GPU image if resident (marks it used, queues a DPI upgrade
+ /// when the resident render is stale); otherwise queues a render (once)
+ /// and returns None.
+ pub fn ensure(&mut self, doc: &Document, quarter_turns: u8, page: usize, want_dpi: u32) -> Option<Rendered> {
+ let job = |dpi| Job {
+ generation: self.generation,
+ page,
+ dpi,
+ path: doc.path.clone(),
+ kind: doc.kind,
+ size: doc.pages[page],
+ quarter_turns,
+ };
+ match self.states.get_mut(&page) {
+ Some(PageState::Ready { r, refreshing, last_used }) => {
+ *last_used = self.frame;
+ if r.dpi != want_dpi && doc.kind == Kind::Pdf && !*refreshing {
+ *refreshing = true;
+ let _ = self.queue.send(job(want_dpi));
+ }
+ Some(*r)
+ }
+ Some(_) => None,
+ None => {
+ self.states.insert(page, PageState::Pending);
+ let _ = self.queue.send(job(want_dpi));
+ None
+ }
+ }
+ }
+
+ pub fn complete(&mut self, generation: u64, page: usize, result: Option<Rendered>) {
+ if generation != self.generation {
+ if let Some(r) = result {
+ cce_ui::vk::free_image(r.image);
+ }
+ return;
+ }
+ let state = match result {
+ Some(r) => PageState::Ready { r, refreshing: false, last_used: self.frame },
+ None => PageState::Failed,
+ };
+ if let Some(PageState::Ready { r, .. }) = self.states.insert(page, state) {
+ cce_ui::vk::free_image(r.image);
+ }
+ self.evict();
+ }
+
+ /// Free the least-recently-used pages once over budget; pages touched
+ /// this frame are never evicted.
+ fn evict(&mut self) {
+ let resident = self.states.values().filter(|s| matches!(s, PageState::Ready { .. })).count();
+ if resident <= MAX_GPU_PAGES {
+ return;
+ }
+ let mut ready: Vec<(usize, u64)> = self
+ .states
+ .iter()
+ .filter_map(|(p, s)| match s {
+ PageState::Ready { last_used, .. } if *last_used < self.frame => Some((*p, *last_used)),
+ _ => None,
+ })
+ .collect();
+ ready.sort_by_key(|&(_, used)| used);
+ for (page, _) in ready.into_iter().take(resident - MAX_GPU_PAGES) {
+ if let Some(PageState::Ready { r, .. }) = self.states.remove(&page) {
+ cce_ui::vk::free_image(r.image);
+ }
+ }
+ }
+}
+
+fn worker(rx: Arc<Mutex<mpsc::Receiver<Job>>>, notify: calloop::channel::Sender<Message>) {
+ loop {
+ let job = match rx.lock().unwrap().recv() {
+ Ok(j) => j,
+ Err(_) => return,
+ };
+ let result = render(&job)
+ .map_err(|e| log::warn!("{}: page {}: {e}", job.path.display(), job.page + 1))
+ .ok();
+ let msg = Message::Page { generation: job.generation, page: job.page, result };
+ if notify.send(msg).is_err() {
+ return;
+ }
+ }
+}
+
+fn render(job: &Job) -> Result<Rendered, String> {
+ let mut rgba = match job.kind {
+ Kind::Image => {
+ let img = image::open(&job.path).map_err(|e| e.to_string())?;
+ let mut rgba = img.to_rgba8();
+ let (w, h) = rgba.dimensions();
+ if w.max(h) > MAX_DIM {
+ let s = MAX_DIM as f64 / w.max(h) as f64;
+ let (nw, nh) = (((w as f64 * s) as u32).max(1), ((h as f64 * s) as u32).max(1));
+ rgba = image::imageops::resize(&rgba, nw, nh, image::imageops::FilterType::Triangle);
+ }
+ rgba
+ }
+ Kind::Pdf => {
+ // Cap the DPI so the page bitmap stays under MAX_DIM on its
+ // longer edge (page size is in points, 72/inch).
+ let max_pts = job.size.w.max(job.size.h).max(1.0);
+ let dpi = (job.dpi as f64).min(MAX_DIM as f64 * 72.0 / max_pts).max(18.0) as u32;
+ let page = (job.page + 1).to_string();
+ // No output root: poppler's pdftoppm writes the PNG to stdout.
+ let out = Command::new("pdftoppm")
+ .args(["-png", "-r", &dpi.to_string(), "-f", &page, "-l", &page])
+ .arg(&job.path)
+ .output()
+ .map_err(|e| format!("pdftoppm: {e}"))?;
+ if !out.status.success() || out.stdout.is_empty() {
+ return Err(format!("pdftoppm: {}", String::from_utf8_lossy(&out.stderr).trim()));
+ }
+ image::load_from_memory(&out.stdout).map_err(|e| e.to_string())?.to_rgba8()
+ }
+ };
+ match job.quarter_turns % 4 {
+ 1 => rgba = image::imageops::rotate90(&rgba),
+ 2 => rgba = image::imageops::rotate180(&rgba),
+ 3 => rgba = image::imageops::rotate270(&rgba),
+ _ => {}
+ }
+ let (w, h) = rgba.dimensions();
+ let image = cce_ui::vk::upload_rgba(rgba.into_raw(), w, h);
+ Ok(Rendered { image, dpi: job.dpi })
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..0fd4004
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,518 @@
+//! cce-preview — document and image viewer in the spirit of macOS Preview.
+//!
+//! One continuous vertically-scrolled document: PDF pages (rasterized
+//! lazily per page via poppler's pdftoppm, re-rendered at higher DPI as
+//! you zoom) or a single raster image. View state is a zoom factor
+//! (screen px per document unit) plus a scroll offset; pages are laid out
+//! in document units so zoom-at-pointer is an exact rescale.
+//!
+//! Keys: o open · +/- zoom · 0 fit · 1 actual size · r/l rotate ·
+//! arrows/PageUp/PageDown pages (or prev/next file for images) · q quit.
+//! Wheel scrolls, ctrl+wheel and pinch zoom at the pointer, drag pans.
+
+mod doc;
+
+use std::path::{Path, PathBuf};
+
+use wayland_client::QueueHandle;
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Position};
+
+use doc::{Document, PageStore, Rendered, IMAGE_EXTS};
+
+/// Vertical gap between pages, in document units (so the layout scales
+/// uniformly with zoom and anchored zooming stays exact).
+const GAP_UNITS: f64 = 12.0;
+/// Margin left around a fitted page.
+const FIT_MARGIN: f64 = 24.0;
+const WHEEL_SCROLL_PX: f64 = 48.0;
+const KEY_SCROLL_PX: f64 = 80.0;
+const ZOOM_MIN: f64 = 0.05;
+const ZOOM_MAX: f64 = 16.0;
+/// DPI steps pages are rendered at; bucketing keeps small zoom jitters from
+/// re-rasterizing every page.
+const DPI_BUCKETS: &[u32] = &[36, 48, 72, 96, 144, 192, 288, 384, 576];
+
+#[derive(Debug, Clone)]
+enum Message {
+ Page { generation: u64, page: usize, result: Option<Rendered> },
+ Quit,
+}
+
+struct PreviewApp {
+ store: PageStore,
+ doc: Option<Document>,
+ error: Option<String>,
+ /// Sibling files for ArrowLeft/Right browsing (CLI args, or the images
+ /// in the opened file's directory).
+ files: Vec<PathBuf>,
+ file_idx: usize,
+ /// User rotation in quarter turns clockwise, whole-document.
+ quarter_turns: u8,
+ /// Screen px per document unit (pt for PDFs, source px for images).
+ zoom: f64,
+ /// Scroll offset in screen px; 0 when the content fits the window.
+ scroll: (f64, f64),
+ /// Refit on resize until the user zooms manually.
+ fit: bool,
+ win: (f32, f32),
+ scale: f64,
+ pointer: (f64, f64),
+ drag: Option<(f64, f64)>,
+ ctrl: bool,
+ shift: bool,
+}
+
+/// Per-page layout rect in document units.
+struct PageRect {
+ x: f64,
+ y: f64,
+ w: f64,
+ h: f64,
+}
+
+impl PreviewApp {
+ fn rotated(&self, page: doc::PageSize) -> (f64, f64) {
+ if self.quarter_turns % 2 == 1 {
+ (page.h, page.w)
+ } else {
+ (page.w, page.h)
+ }
+ }
+
+ /// Page rects stacked vertically, centered in the content width.
+ fn layout(&self) -> (Vec<PageRect>, f64, f64) {
+ let Some(doc) = &self.doc else { return (Vec::new(), 0.0, 0.0) };
+ let content_w = doc.pages.iter().map(|p| self.rotated(*p).0).fold(0.0, f64::max);
+ let mut rects = Vec::with_capacity(doc.pages.len());
+ let mut y = 0.0;
+ for page in &doc.pages {
+ let (w, h) = self.rotated(*page);
+ rects.push(PageRect { x: (content_w - w) / 2.0, y, w, h });
+ y += h + GAP_UNITS;
+ }
+ (rects, content_w, y - GAP_UNITS)
+ }
+
+ /// Top-left of the content in screen coords: centered when it fits,
+ /// scrolled when it doesn't.
+ fn origin(&self, content_w: f64, content_h: f64) -> (f64, f64) {
+ let (w, h) = (self.win.0 as f64, self.win.1 as f64);
+ let ox = ((w - content_w * self.zoom) / 2.0).max(0.0) - self.scroll.0;
+ let oy = ((h - content_h * self.zoom) / 2.0).max(0.0) - self.scroll.1;
+ (ox, oy)
+ }
+
+ fn clamp_scroll(&mut self) {
+ let (_, cw, ch) = self.layout();
+ let (w, h) = (self.win.0 as f64, self.win.1 as f64);
+ self.scroll.0 = self.scroll.0.clamp(0.0, (cw * self.zoom - w).max(0.0));
+ self.scroll.1 = self.scroll.1.clamp(0.0, (ch * self.zoom - h).max(0.0));
+ }
+
+ fn scroll_by(&mut self, dx: f64, dy: f64) {
+ self.scroll.0 += dx;
+ self.scroll.1 += dy;
+ self.clamp_scroll();
+ }
+
+ /// Multiply zoom, keeping the document point under (px, py) fixed.
+ fn zoom_at(&mut self, factor: f64, px: f64, py: f64) {
+ let (_, cw, ch) = self.layout();
+ let (ox, oy) = self.origin(cw, ch);
+ let (dx, dy) = ((px - ox) / self.zoom, (py - oy) / self.zoom);
+ self.zoom = (self.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
+ self.fit = false;
+ let (w, h) = (self.win.0 as f64, self.win.1 as f64);
+ let pad_x = ((w - cw * self.zoom) / 2.0).max(0.0);
+ let pad_y = ((h - ch * self.zoom) / 2.0).max(0.0);
+ self.scroll.0 = pad_x - (px - dx * self.zoom);
+ self.scroll.1 = pad_y - (py - dy * self.zoom);
+ self.clamp_scroll();
+ }
+
+ /// The page overlapping the viewport center (for HUD and refit).
+ fn current_page(&self) -> usize {
+ let (rects, cw, ch) = self.layout();
+ let (_, oy) = self.origin(cw, ch);
+ let mid = (self.win.1 as f64 / 2.0 - oy) / self.zoom;
+ rects
+ .iter()
+ .position(|r| mid < r.y + r.h + GAP_UNITS / 2.0)
+ .unwrap_or(rects.len().saturating_sub(1))
+ }
+
+ /// Fit the given page inside the window and scroll to its top.
+ fn fit_page(&mut self, page: usize) {
+ let (rects, _, _) = self.layout();
+ let Some(r) = rects.get(page) else { return };
+ let (w, h) = ((self.win.0 as f64 - FIT_MARGIN).max(64.0), (self.win.1 as f64 - FIT_MARGIN).max(64.0));
+ self.zoom = (w / r.w).min(h / r.h).clamp(ZOOM_MIN, ZOOM_MAX);
+ self.fit = true;
+ self.scroll = (0.0, r.y * self.zoom);
+ self.clamp_scroll();
+ }
+
+ fn go_to_page(&mut self, page: usize) {
+ let (rects, _, _) = self.layout();
+ if let Some(r) = rects.get(page) {
+ self.scroll.1 = (r.y - GAP_UNITS / 2.0) * self.zoom;
+ self.clamp_scroll();
+ }
+ }
+
+ fn open(&mut self, path: &Path, rebuild_collection: bool) {
+ self.store.reset();
+ self.quarter_turns = 0;
+ self.error = None;
+ match Document::load(path) {
+ Ok(d) => {
+ self.doc = Some(d);
+ self.fit_page(0);
+ }
+ Err(e) => {
+ self.doc = None;
+ self.error = Some(format!("{}: {e}", path.display()));
+ }
+ }
+ if rebuild_collection {
+ (self.files, self.file_idx) = collection_for(path);
+ }
+ }
+
+ fn open_sibling(&mut self, step: i64) {
+ if self.files.len() < 2 {
+ return;
+ }
+ let n = self.files.len() as i64;
+ self.file_idx = ((self.file_idx as i64 + step).rem_euclid(n)) as usize;
+ let path = self.files[self.file_idx].clone();
+ self.open(&path, false);
+ }
+
+ fn open_dialog(&mut self) {
+ let filters: &[(&str, &[&str])] = &[
+ ("Documents & images", &["pdf", "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "ico"]),
+ ("PDF", &["pdf"]),
+ ("Images", IMAGE_EXTS),
+ ];
+ if let Some(path) = cce_ui::file_dialog::pick_file("Open", filters) {
+ self.open(&path, true);
+ }
+ }
+
+ fn rotate(&mut self, quarter_turns_cw: i8) {
+ self.quarter_turns = (self.quarter_turns as i8 + quarter_turns_cw).rem_euclid(4) as u8;
+ self.store.reset();
+ self.clamp_scroll();
+ if self.fit {
+ self.fit_page(self.current_page());
+ }
+ }
+
+ fn notches(delta: &MouseScrollDelta) -> (f64, f64) {
+ match delta {
+ MouseScrollDelta::LineDelta(x, y) => (*x as f64, *y as f64),
+ MouseScrollDelta::PixelDelta(Position { x, y }) => (x / 60.0, y / 60.0),
+ }
+ }
+}
+
+/// The file's siblings for arrow-key browsing: images in the same
+/// directory, name-sorted, with the opened file's position. PDFs browse
+/// their own pages instead, so they get a singleton collection.
+fn collection_for(path: &Path) -> (Vec<PathBuf>, usize) {
+ let is_image = path
+ .extension()
+ .and_then(|e| e.to_str())
+ .is_some_and(|e| IMAGE_EXTS.contains(&e.to_ascii_lowercase().as_str()));
+ if !is_image {
+ return (vec![path.to_path_buf()], 0);
+ }
+ let mut files: Vec<PathBuf> = path
+ .parent()
+ .and_then(|dir| std::fs::read_dir(dir).ok())
+ .into_iter()
+ .flatten()
+ .filter_map(|e| e.ok().map(|e| e.path()))
+ .filter(|p| {
+ p.extension()
+ .and_then(|e| e.to_str())
+ .is_some_and(|e| IMAGE_EXTS.contains(&e.to_ascii_lowercase().as_str()))
+ })
+ .collect();
+ files.sort();
+ let idx = files.iter().position(|p| p == path).unwrap_or(0);
+ if files.is_empty() {
+ (vec![path.to_path_buf()], 0)
+ } else {
+ (files, idx)
+ }
+}
+
+impl Application for PreviewApp {
+ type Message = Message;
+
+ fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+ let mut app = Self {
+ store: PageStore::new(sender),
+ doc: None,
+ error: None,
+ files: Vec::new(),
+ file_idx: 0,
+ quarter_turns: 0,
+ zoom: 1.0,
+ scroll: (0.0, 0.0),
+ fit: true,
+ win: (900.0, 700.0),
+ scale: 1.0,
+ pointer: (0.0, 0.0),
+ drag: None,
+ ctrl: false,
+ shift: false,
+ };
+ let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
+ match args.len() {
+ 0 => {}
+ 1 => app.open(&args[0].clone(), true),
+ _ => {
+ app.files = args;
+ app.file_idx = 0;
+ let path = app.files[0].clone();
+ app.open(&path, false);
+ }
+ }
+ app
+ }
+
+ fn settings(&self) -> WindowSettings {
+ let title = match &self.doc {
+ Some(d) => {
+ let name = d.path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
+ if d.pages.len() > 1 {
+ format!("{name} (page {}/{}) — Preview", self.current_page() + 1, d.pages.len())
+ } else {
+ format!("{name} — Preview")
+ }
+ }
+ None => "Preview".to_string(),
+ };
+ WindowSettings {
+ title,
+ app_id: "cce-preview".to_string(),
+ width: 900,
+ height: 700,
+ fullscreen: false,
+ min_size: Some((320, 240)),
+ }
+ }
+
+ fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
+ match msg {
+ Message::Page { generation, page, result } => {
+ self.store.complete(generation, page, result);
+ *needs_rebuild = true;
+ }
+ Message::Quit => *exit = true,
+ }
+ }
+
+ fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+ fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
+ self.win = (width, height);
+ self.scale = scale;
+ if self.fit {
+ self.fit_page(self.current_page());
+ } else {
+ self.clamp_scroll();
+ }
+ }
+
+ fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ let (px, py) = (pos.x as f64, pos.y as f64);
+ if let Some((lx, ly)) = self.drag {
+ self.scroll_by(lx - px, ly - py);
+ self.drag = Some((px, py));
+ *needs_rebuild = true;
+ }
+ self.pointer = (px, py);
+ }
+
+ fn handle_mouse_input(
+ &mut self,
+ button: MouseButton,
+ state: ElementState,
+ pos: LogicalPosition,
+ _needs_rebuild: &mut bool,
+ ) -> Option<Self::Message> {
+ if button == MouseButton::Left {
+ self.drag = match state {
+ ElementState::Pressed => Some((pos.x as f64, pos.y as f64)),
+ ElementState::Released => None,
+ };
+ }
+ None
+ }
+
+ fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ let (nx, ny) = Self::notches(delta);
+ if nx == 0.0 && ny == 0.0 {
+ return;
+ }
+ if self.ctrl {
+ self.zoom_at(1.1f64.powf(ny), pos.x as f64, pos.y as f64);
+ } else if self.shift {
+ self.scroll_by(-ny * WHEEL_SCROLL_PX, 0.0);
+ } else {
+ self.scroll_by(-nx * WHEEL_SCROLL_PX, -ny * WHEEL_SCROLL_PX);
+ }
+ *needs_rebuild = true;
+ }
+
+ fn handle_pinch(&mut self, factor: f32, pos: LogicalPosition, needs_rebuild: &mut bool) -> bool {
+ if factor > 0.0 && factor != 1.0 {
+ self.zoom_at(factor as f64, pos.x as f64, pos.y as f64);
+ *needs_rebuild = true;
+ }
+ true
+ }
+
+ fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+ // Wheel events carry no modifiers, so track ctrl/shift from the key
+ // stream for ctrl+wheel zoom / shift+wheel horizontal scroll.
+ match &event.logical_key {
+ Key::Named(NamedKey::Control) => self.ctrl = event.state == ElementState::Pressed,
+ Key::Named(NamedKey::Shift) => self.shift = event.state == ElementState::Pressed,
+ _ => {
+ self.ctrl = event.ctrl;
+ self.shift = event.shift;
+ }
+ }
+ if event.state != ElementState::Pressed {
+ return None;
+ }
+ log::debug!("key: {:?} text={:?} ctrl={} shift={}", event.logical_key, event.text, event.ctrl, event.shift);
+ let (cx, cy) = (self.win.0 as f64 / 2.0, self.win.1 as f64 / 2.0);
+ let pages = self.doc.as_ref().map_or(0, |d| d.pages.len());
+ let file_nav = pages <= 1 && self.files.len() > 1;
+ let mut handled = true;
+ match &event.logical_key {
+ Key::Character(c) if c == "+" || c == "=" => self.zoom_at(1.25, cx, cy),
+ Key::Character(c) if c == "-" => self.zoom_at(0.8, cx, cy),
+ Key::Character(c) if c == "0" => self.fit_page(self.current_page()),
+ Key::Character(c) if c == "1" => {
+ let f = 1.0 / self.zoom;
+ self.zoom_at(f, cx, cy);
+ }
+ Key::Character(c) if c == "r" || c == "R" => self.rotate(1),
+ Key::Character(c) if c == "l" || c == "L" => self.rotate(-1),
+ Key::Character(c) if c == "o" => self.open_dialog(),
+ Key::Character(c) if c == "q" => return Some(Message::Quit),
+ Key::Named(NamedKey::ArrowUp) => self.scroll_by(0.0, -KEY_SCROLL_PX),
+ Key::Named(NamedKey::ArrowDown) => self.scroll_by(0.0, KEY_SCROLL_PX),
+ Key::Named(NamedKey::ArrowLeft) if file_nav => self.open_sibling(-1),
+ Key::Named(NamedKey::ArrowRight) if file_nav => self.open_sibling(1),
+ Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::PageUp) => {
+ let p = self.current_page();
+ self.go_to_page(p.saturating_sub(1));
+ }
+ Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::PageDown) => {
+ let p = self.current_page();
+ self.go_to_page((p + 1).min(pages.saturating_sub(1)));
+ }
+ Key::Named(NamedKey::Space) => self.scroll_by(0.0, self.win.1 as f64 * 0.9),
+ Key::Named(NamedKey::Home) => self.go_to_page(0),
+ Key::Named(NamedKey::End) => self.go_to_page(pages.saturating_sub(1)),
+ _ => handled = false,
+ }
+ if handled {
+ *needs_rebuild = true;
+ }
+ None
+ }
+
+ fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+ self.win = (size.width, size.height);
+ self.scale = scale;
+ self.store.begin_frame();
+ let mut pc = PaintCtx::new();
+ pc.quad(Rect { x: 0.0, y: 0.0, width: size.width, height: size.height }, self.clear_color());
+
+ if self.doc.is_none() {
+ let msg = self.error.as_deref().unwrap_or("Press 'o' to open a file");
+ pc.text(msg, 24.0, size.height / 2.0 - 8.0, 14.0, [180, 180, 180]);
+ return Some(pc.finish());
+ }
+
+ let (rects, cw, ch) = self.layout();
+ let (ox, oy) = self.origin(cw, ch);
+ let want_dpi = {
+ let want = 72.0 * self.zoom * scale;
+ *DPI_BUCKETS
+ .iter()
+ .find(|&&b| want <= b as f64 * 1.01)
+ .unwrap_or(DPI_BUCKETS.last().unwrap())
+ };
+
+ let mut visible = Vec::new();
+ for (i, r) in rects.iter().enumerate() {
+ let rect = Rect {
+ x: (ox + r.x * self.zoom) as f32,
+ y: (oy + r.y * self.zoom) as f32,
+ width: (r.w * self.zoom) as f32,
+ height: (r.h * self.zoom) as f32,
+ };
+ if rect.y > size.height || rect.y + rect.height < 0.0 {
+ continue;
+ }
+ visible.push((i, rect));
+ }
+ let doc = self.doc.take().unwrap();
+ for (i, rect) in &visible {
+ // White page ground: placeholder while rendering, and backing
+ // for images with transparency.
+ pc.quad(
+ Rect { x: rect.x - 1.0, y: rect.y - 1.0, width: rect.width + 2.0, height: rect.height + 2.0 },
+ [0.0, 0.0, 0.0, 0.35],
+ );
+ pc.quad(*rect, [0.97, 0.97, 0.97, 1.0]);
+ if let Some(r) = self.store.ensure(&doc, self.quarter_turns, *i, want_dpi) {
+ pc.image(r.image, *rect, 1.0);
+ }
+ }
+ self.doc = Some(doc);
+
+ // HUD: file name, page, zoom (top-left chip).
+ let doc = self.doc.as_ref().unwrap();
+ let name = doc.path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
+ let mut hud = name.to_string();
+ if doc.pages.len() > 1 {
+ hud.push_str(&format!(" · page {}/{}", self.current_page() + 1, doc.pages.len()));
+ } else if self.files.len() > 1 {
+ hud.push_str(&format!(" · {}/{}", self.file_idx + 1, self.files.len()));
+ }
+ hud.push_str(&format!(" · {:.0}%", self.zoom * 100.0));
+ let w = 24.0 + hud.chars().count() as f32 * 6.6;
+ pc.quad(Rect { x: 8.0, y: 8.0, width: w, height: 24.0 }, [0.0, 0.0, 0.0, 0.45]);
+ pc.text(hud, 16.0, 13.0, 12.0, [230, 230, 230]);
+
+ Some(pc.finish())
+ }
+
+ fn display_list_text(&self) -> bool {
+ true
+ }
+
+ fn clear_color(&self) -> [f32; 4] {
+ [0.13, 0.13, 0.14, 1.0]
+ }
+}
+
+fn main() {
+ env_logger::init();
+ cce_ui::engine::run::<PreviewApp>();
+}