git.lucas.co / cce-preview
document and image viewer
git clone https://git.lucas.co/cce-preview.git

src/main.rs (23K)

  1 //! cce-preview — document and image viewer in the spirit of macOS Preview.
  2 //!
  3 //! One continuous vertically-scrolled document: PDF pages (rasterized
  4 //! lazily per page via poppler's pdftoppm, re-rendered at higher DPI as
  5 //! you zoom) or a single raster image. View state is a zoom factor
  6 //! (screen px per document unit) plus a scroll offset; pages are laid out
  7 //! in document units so zoom-at-pointer is an exact rescale.
  8 //!
  9 //! Keys: o open · +/- zoom · 0 fit · 1 actual size · r/l rotate ·
 10 //! arrows/PageUp/PageDown pages (or prev/next file for images) · q quit.
 11 //! Wheel scrolls, ctrl+wheel and pinch zoom at the pointer, drag pans.
 12 
 13 mod doc;
 14 
 15 use std::path::{Path, PathBuf};
 16 
 17 
 18 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
 19 use cce_ui::scene::layout::Rect;
 20 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 21 use cce_ui::widget::scroll_motion::{Bounds, ScrollMotion};
 22 use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Position};
 23 
 24 use doc::{Document, PageStore, Rendered, IMAGE_EXTS};
 25 
 26 /// Vertical gap between pages, in document units (so the layout scales
 27 /// uniformly with zoom and anchored zooming stays exact).
 28 const GAP_UNITS: f64 = 12.0;
 29 /// Margin left around a fitted page.
 30 /// Room around a fitted page: the root plate's inset on each side.
 31 fn fit_margin() -> f64 {
 32     2.0 * cce_ui::layout::root_plate_inset() as f64
 33 }
 34 const WHEEL_SCROLL_PX: f64 = 48.0;
 35 const KEY_SCROLL_PX: f64 = 80.0;
 36 const ZOOM_MIN: f64 = 0.05;
 37 const ZOOM_MAX: f64 = 16.0;
 38 /// DPI steps pages are rendered at; bucketing keeps small zoom jitters from
 39 /// re-rasterizing every page.
 40 const DPI_BUCKETS: &[u32] = &[36, 48, 72, 96, 144, 192, 288, 384, 576];
 41 
 42 #[derive(Debug, Clone)]
 43 enum Message {
 44     Page { generation: u64, page: usize, result: Option<Rendered> },
 45     Quit,
 46 }
 47 
 48 struct PreviewApp {
 49     store: PageStore,
 50     doc: Option<Document>,
 51     error: Option<String>,
 52     /// Sibling files for ArrowLeft/Right browsing (CLI args, or the images
 53     /// in the opened file's directory).
 54     files: Vec<PathBuf>,
 55     file_idx: usize,
 56     /// User rotation in quarter turns clockwise, whole-document.
 57     quarter_turns: u8,
 58     /// Screen px per document unit (pt for PDFs, source px for images).
 59     zoom: f64,
 60     /// Scroll offset in screen px; 0 when the content fits the window.
 61     scroll: (f64, f64),
 62     /// Drives `scroll` (the drawn value) from the wheel: notches glide,
 63     /// fingers track 1:1 and fling on the lift. Drag, keyboard and zoom
 64     /// write `scroll` directly; the motion adopts those through `reconcile`.
 65     scroll_motion: ScrollMotion,
 66     /// Refit on resize until the user zooms manually.
 67     fit: bool,
 68     win: (f32, f32),
 69     scale: f64,
 70     pointer: (f64, f64),
 71     drag: Option<(f64, f64)>,
 72     ctrl: bool,
 73     shift: bool,
 74     /// Whether a renderer has been handed over yet — the first one is the
 75     /// process's own, any later one is a replacement after a reconnect.
 76     /// See `renderer_init`.
 77     seen_renderer: bool,
 78 }
 79 
 80 /// Per-page layout rect in document units.
 81 struct PageRect {
 82     x: f64,
 83     y: f64,
 84     w: f64,
 85     h: f64,
 86 }
 87 
 88 impl PreviewApp {
 89     fn rotated(&self, page: doc::PageSize) -> (f64, f64) {
 90         if self.quarter_turns % 2 == 1 {
 91             (page.h, page.w)
 92         } else {
 93             (page.w, page.h)
 94         }
 95     }
 96 
 97     /// Page rects stacked vertically, centered in the content width.
 98     fn layout(&self) -> (Vec<PageRect>, f64, f64) {
 99         let Some(doc) = &self.doc else { return (Vec::new(), 0.0, 0.0) };
100         let content_w = doc.pages.iter().map(|p| self.rotated(*p).0).fold(0.0, f64::max);
101         let mut rects = Vec::with_capacity(doc.pages.len());
102         let mut y = 0.0;
103         for page in &doc.pages {
104             let (w, h) = self.rotated(*page);
105             rects.push(PageRect { x: (content_w - w) / 2.0, y, w, h });
106             y += h + GAP_UNITS;
107         }
108         (rects, content_w, y - GAP_UNITS)
109     }
110 
111     /// Top-left of the content in screen coords: centered when it fits,
112     /// scrolled when it doesn't.
113     fn origin(&self, content_w: f64, content_h: f64) -> (f64, f64) {
114         let (w, h) = (self.win.0 as f64, self.win.1 as f64);
115         let ox = ((w - content_w * self.zoom) / 2.0).max(0.0) - self.scroll.0;
116         let oy = ((h - content_h * self.zoom) / 2.0).max(0.0) - self.scroll.1;
117         (ox, oy)
118     }
119 
120     fn clamp_scroll(&mut self) {
121         let (_, cw, ch) = self.layout();
122         let (w, h) = (self.win.0 as f64, self.win.1 as f64);
123         self.scroll.0 = self.scroll.0.clamp(0.0, (cw * self.zoom - w).max(0.0));
124         self.scroll.1 = self.scroll.1.clamp(0.0, (ch * self.zoom - h).max(0.0));
125     }
126 
127     fn scroll_by(&mut self, dx: f64, dy: f64) {
128         self.scroll.0 += dx;
129         self.scroll.1 += dy;
130         self.clamp_scroll();
131     }
132 
133     /// The wheel's range per axis, `0..=overflow` — what `clamp_scroll` clamps to.
134     fn scroll_bounds(&self) -> (Bounds, Bounds) {
135         let (_, cw, ch) = self.layout();
136         let (w, h) = (self.win.0 as f64, self.win.1 as f64);
137         (Bounds::max((cw * self.zoom - w) as f32), Bounds::max((ch * self.zoom - h) as f32))
138     }
139 
140     /// Copy the motion's position into `scroll` exactly (the f32 round-trips
141     /// losslessly, so the next `reconcile` sees no host write).
142     fn sync_scroll_from_motion(&mut self) {
143         self.scroll = (self.scroll_motion.x.pos() as f64, self.scroll_motion.y.pos() as f64);
144     }
145 
146     /// Per-frame wheel glide/coast; true while `scroll` is still moving, so
147     /// the frame loop keeps drawing.
148     fn tick_scroll(&mut self, dt: f32) -> bool {
149         self.scroll_motion.reconcile(self.scroll.0 as f32, self.scroll.1 as f32);
150         if !self.scroll_motion.is_animating() {
151             return false;
152         }
153         let (bx, by) = self.scroll_bounds();
154         let moved = self.scroll_motion.tick(dt, bx, by);
155         self.sync_scroll_from_motion();
156         moved || self.scroll_motion.is_animating()
157     }
158 
159     /// Multiply zoom, keeping the document point under (px, py) fixed.
160     fn zoom_at(&mut self, factor: f64, px: f64, py: f64) {
161         let (_, cw, ch) = self.layout();
162         let (ox, oy) = self.origin(cw, ch);
163         let (dx, dy) = ((px - ox) / self.zoom, (py - oy) / self.zoom);
164         self.zoom = (self.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
165         self.fit = false;
166         let (w, h) = (self.win.0 as f64, self.win.1 as f64);
167         let pad_x = ((w - cw * self.zoom) / 2.0).max(0.0);
168         let pad_y = ((h - ch * self.zoom) / 2.0).max(0.0);
169         self.scroll.0 = pad_x - (px - dx * self.zoom);
170         self.scroll.1 = pad_y - (py - dy * self.zoom);
171         self.clamp_scroll();
172     }
173 
174     /// The page overlapping the viewport center (for HUD and refit).
175     fn current_page(&self) -> usize {
176         let (rects, cw, ch) = self.layout();
177         let (_, oy) = self.origin(cw, ch);
178         let mid = (self.win.1 as f64 / 2.0 - oy) / self.zoom;
179         rects
180             .iter()
181             .position(|r| mid < r.y + r.h + GAP_UNITS / 2.0)
182             .unwrap_or(rects.len().saturating_sub(1))
183     }
184 
185     /// Fit the given page inside the window and scroll to its top.
186     fn fit_page(&mut self, page: usize) {
187         let (rects, _, _) = self.layout();
188         let Some(r) = rects.get(page) else { return };
189         let (w, h) = ((self.win.0 as f64 - fit_margin()).max(64.0), (self.win.1 as f64 - fit_margin()).max(64.0));
190         self.zoom = (w / r.w).min(h / r.h).clamp(ZOOM_MIN, ZOOM_MAX);
191         self.fit = true;
192         self.scroll = (0.0, r.y * self.zoom);
193         self.clamp_scroll();
194     }
195 
196     fn go_to_page(&mut self, page: usize) {
197         let (rects, _, _) = self.layout();
198         if let Some(r) = rects.get(page) {
199             self.scroll.1 = (r.y - GAP_UNITS / 2.0) * self.zoom;
200             self.clamp_scroll();
201         }
202     }
203 
204     fn open(&mut self, path: &Path, rebuild_collection: bool) {
205         self.store.reset();
206         self.quarter_turns = 0;
207         self.error = None;
208         match Document::load(path) {
209             Ok(d) => {
210                 self.doc = Some(d);
211                 self.fit_page(0);
212             }
213             Err(e) => {
214                 self.doc = None;
215                 self.error = Some(format!("{}: {e}", path.display()));
216             }
217         }
218         if rebuild_collection {
219             (self.files, self.file_idx) = collection_for(path);
220         }
221     }
222 
223     fn open_sibling(&mut self, step: i64) {
224         if self.files.len() < 2 {
225             return;
226         }
227         let n = self.files.len() as i64;
228         self.file_idx = ((self.file_idx as i64 + step).rem_euclid(n)) as usize;
229         let path = self.files[self.file_idx].clone();
230         self.open(&path, false);
231     }
232 
233     fn open_dialog(&mut self) {
234         let filters: &[(&str, &[&str])] = &[
235             ("Documents & images", &["pdf", "png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "ico"]),
236             ("PDF", &["pdf"]),
237             ("Images", IMAGE_EXTS),
238         ];
239         if let Some(path) = cce_ui::file_dialog::pick_file("Open", filters) {
240             self.open(&path, true);
241         }
242     }
243 
244     fn rotate(&mut self, quarter_turns_cw: i8) {
245         self.quarter_turns = (self.quarter_turns as i8 + quarter_turns_cw).rem_euclid(4) as u8;
246         self.store.reset();
247         self.clamp_scroll();
248         if self.fit {
249             self.fit_page(self.current_page());
250         }
251     }
252 
253     fn notches(delta: &MouseScrollDelta) -> (f64, f64) {
254         match delta {
255             MouseScrollDelta::LineDelta(x, y) => (*x as f64, *y as f64),
256             MouseScrollDelta::PixelDelta(Position { x, y }) => (x / 60.0, y / 60.0),
257         }
258     }
259 }
260 
261 /// The file's siblings for arrow-key browsing: images in the same
262 /// directory, name-sorted, with the opened file's position. PDFs browse
263 /// their own pages instead, so they get a singleton collection.
264 fn collection_for(path: &Path) -> (Vec<PathBuf>, usize) {
265     let is_image = path
266         .extension()
267         .and_then(|e| e.to_str())
268         .is_some_and(|e| IMAGE_EXTS.contains(&e.to_ascii_lowercase().as_str()));
269     if !is_image {
270         return (vec![path.to_path_buf()], 0);
271     }
272     let mut files: Vec<PathBuf> = path
273         .parent()
274         .and_then(|dir| std::fs::read_dir(dir).ok())
275         .into_iter()
276         .flatten()
277         .filter_map(|e| e.ok().map(|e| e.path()))
278         .filter(|p| {
279             p.extension()
280                 .and_then(|e| e.to_str())
281                 .is_some_and(|e| IMAGE_EXTS.contains(&e.to_ascii_lowercase().as_str()))
282         })
283         .collect();
284     files.sort();
285     let idx = files.iter().position(|p| p == path).unwrap_or(0);
286     if files.is_empty() {
287         (vec![path.to_path_buf()], 0)
288     } else {
289         (files, idx)
290     }
291 }
292 
293 impl Application for PreviewApp {
294     type Message = Message;
295 
296     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
297         // The app keeps calloop's sender; `AppSender` converts into it.
298         let sender: calloop::channel::Sender<Self::Message> = sender.into();
299         let mut app = Self {
300             store: PageStore::new(sender),
301             doc: None,
302             error: None,
303             files: Vec::new(),
304             file_idx: 0,
305             quarter_turns: 0,
306             zoom: 1.0,
307             scroll: (0.0, 0.0),
308             scroll_motion: ScrollMotion::new(),
309             fit: true,
310             win: (900.0, 700.0),
311             scale: 1.0,
312             pointer: (0.0, 0.0),
313             drag: None,
314             ctrl: false,
315             shift: false,
316             seen_renderer: false,
317         };
318         let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
319         match args.len() {
320             0 => {}
321             1 => app.open(&args[0].clone(), true),
322             _ => {
323                 app.files = args;
324                 app.file_idx = 0;
325                 let path = app.files[0].clone();
326                 app.open(&path, false);
327             }
328         }
329         app
330     }
331 
332     fn settings(&self) -> WindowSettings {
333         let title = match &self.doc {
334             Some(d) => {
335                 let name = d.path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
336                 if d.pages.len() > 1 {
337                     format!("{name} (page {}/{}) — Preview", self.current_page() + 1, d.pages.len())
338                 } else {
339                     format!("{name} — Preview")
340                 }
341             }
342             None => "Preview".to_string(),
343         };
344         WindowSettings {
345             title,
346             app_id: "cce-preview".to_string(),
347             width: 900,
348             height: 700,
349             fullscreen: false,
350             min_size: Some((320, 240)),
351         }
352     }
353 
354     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
355         match msg {
356             Message::Page { generation, page, result } => {
357                 self.store.complete(generation, page, result);
358                 *needs_rebuild = true;
359             }
360             Message::Quit => *exit = true,
361         }
362     }
363 
364     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
365         if self.tick_scroll(dt) {
366             *needs_rebuild = true;
367         }
368     }
369 
370     /// Throw the resident pages away when the renderer is replaced.
371     ///
372     /// `PageStore` holds **renderer** image ids, and a renderer does not
373     /// outlive its session: `cce-ui`'s `window_runner` repairs a lost Wayland
374     /// transport by opening a new session around the same `Application`, which
375     /// rebuilds the renderer and with it the image table. The cached ids then
376     /// name images that no longer exist, and a draw for an unknown id is
377     /// skipped rather than reported — so a reconnected viewer came back with
378     /// its chrome and a blank document, and stayed that way, because a
379     /// resident page is never re-rendered.
380     ///
381     /// `reset` is exactly the right hammer: it frees every page (a free for an
382     /// id the new renderer never had is a no-op) and bumps the generation, so
383     /// a render still in flight for the old session is dropped on arrival
384     /// instead of landing as a page nobody asked for. The next `display_list`
385     /// finds nothing resident and queues the visible pages again.
386     ///
387     /// Not on the first renderer: the pages queued from `new()` are waiting
388     /// for precisely that one.
389     fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
390         if std::mem::replace(&mut self.seen_renderer, true) {
391             log::info!("[preview] renderer replaced; re-rendering the resident pages");
392             self.store.reset();
393         }
394     }
395 
396     fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
397         self.win = (width, height);
398         self.scale = scale;
399         if self.fit {
400             self.fit_page(self.current_page());
401         } else {
402             self.clamp_scroll();
403         }
404     }
405 
406     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
407         let (px, py) = (pos.x as f64, pos.y as f64);
408         if let Some((lx, ly)) = self.drag {
409             self.scroll_by(lx - px, ly - py);
410             self.drag = Some((px, py));
411             *needs_rebuild = true;
412         }
413         self.pointer = (px, py);
414     }
415 
416     fn handle_mouse_input(
417         &mut self,
418         button: MouseButton,
419         state: ElementState,
420         pos: LogicalPosition,
421         _needs_rebuild: &mut bool,
422     ) -> Option<Self::Message> {
423         if button == MouseButton::Left {
424             self.drag = match state {
425                 ElementState::Pressed => Some((pos.x as f64, pos.y as f64)),
426                 ElementState::Released => None,
427             };
428         }
429         None
430     }
431 
432     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
433         if self.ctrl {
434             // Zoom stays instant: a notch (or 60px of finger) is one 1.1 step.
435             let (_, ny) = Self::notches(delta);
436             if ny != 0.0 {
437                 self.zoom_at(1.1f64.powf(ny), pos.x as f64, pos.y as f64);
438                 *needs_rebuild = true;
439             }
440             return;
441         }
442         // Plain wheel: a 2-D scroll through the motion — a notch is
443         // WHEEL_SCROLL_PX, pixel deltas are 1:1; shift turns the vertical
444         // motion horizontal. `tick_scroll` carries `scroll` after it.
445         let line = WHEEL_SCROLL_PX as f32;
446         let (mut dx, mut dy) = ScrollMotion::delta_px(delta, (line, line));
447         if self.shift {
448             dx = dy;
449             dy = 0.0;
450         }
451         let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
452         let (bx, by) = self.scroll_bounds();
453         self.scroll_motion.reconcile(self.scroll.0 as f32, self.scroll.1 as f32);
454         if self.scroll_motion.apply_px(dx, dy, discrete, bx, by) {
455             self.sync_scroll_from_motion();
456             *needs_rebuild = true;
457         }
458     }
459 
460     fn handle_pinch(&mut self, factor: f32, pos: LogicalPosition, needs_rebuild: &mut bool) -> bool {
461         if factor > 0.0 && factor != 1.0 {
462             self.zoom_at(factor as f64, pos.x as f64, pos.y as f64);
463             *needs_rebuild = true;
464         }
465         true
466     }
467 
468     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
469         // Wheel events carry no modifiers, so track ctrl/shift from the key
470         // stream for ctrl+wheel zoom / shift+wheel horizontal scroll.
471         match &event.logical_key {
472             Key::Named(NamedKey::Control) => self.ctrl = event.state == ElementState::Pressed,
473             Key::Named(NamedKey::Shift) => self.shift = event.state == ElementState::Pressed,
474             _ => {
475                 self.ctrl = event.ctrl;
476                 self.shift = event.shift;
477             }
478         }
479         if event.state != ElementState::Pressed {
480             return None;
481         }
482         log::debug!("key: {:?} text={:?} ctrl={} shift={}", event.logical_key, event.text, event.ctrl, event.shift);
483         let (cx, cy) = (self.win.0 as f64 / 2.0, self.win.1 as f64 / 2.0);
484         let pages = self.doc.as_ref().map_or(0, |d| d.pages.len());
485         let file_nav = pages <= 1 && self.files.len() > 1;
486         let mut handled = true;
487         match &event.logical_key {
488             Key::Character(c) if c == "+" || c == "=" => self.zoom_at(1.25, cx, cy),
489             Key::Character(c) if c == "-" => self.zoom_at(0.8, cx, cy),
490             Key::Character(c) if c == "0" => self.fit_page(self.current_page()),
491             Key::Character(c) if c == "1" => {
492                 let f = 1.0 / self.zoom;
493                 self.zoom_at(f, cx, cy);
494             }
495             Key::Character(c) if c == "r" || c == "R" => self.rotate(1),
496             Key::Character(c) if c == "l" || c == "L" => self.rotate(-1),
497             Key::Character(c) if c == "o" => self.open_dialog(),
498             Key::Character(c) if c == "q" => return Some(Message::Quit),
499             Key::Named(NamedKey::ArrowUp) => self.scroll_by(0.0, -KEY_SCROLL_PX),
500             Key::Named(NamedKey::ArrowDown) => self.scroll_by(0.0, KEY_SCROLL_PX),
501             Key::Named(NamedKey::ArrowLeft) if file_nav => self.open_sibling(-1),
502             Key::Named(NamedKey::ArrowRight) if file_nav => self.open_sibling(1),
503             Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::PageUp) => {
504                 let p = self.current_page();
505                 self.go_to_page(p.saturating_sub(1));
506             }
507             Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::PageDown) => {
508                 let p = self.current_page();
509                 self.go_to_page((p + 1).min(pages.saturating_sub(1)));
510             }
511             Key::Named(NamedKey::Space) => self.scroll_by(0.0, self.win.1 as f64 * 0.9),
512             Key::Named(NamedKey::Home) => self.go_to_page(0),
513             Key::Named(NamedKey::End) => self.go_to_page(pages.saturating_sub(1)),
514             _ => handled = false,
515         }
516         if handled {
517             *needs_rebuild = true;
518         }
519         None
520     }
521 
522     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
523         self.win = (size.width, size.height);
524         self.scale = scale;
525         self.store.begin_frame();
526         let mut pc = PaintCtx::new();
527         // The standard root plate (cce-ui PlateSpec::window); the document is
528         // full-bleed content drawn on it.
529         pc.root_plate(size.width, size.height);
530 
531         if self.doc.is_none() {
532             let msg = self.error.as_deref().unwrap_or("Press 'o' to open a file");
533             pc.text(msg, cce_ui::layout::root_plate_inset(), size.height / 2.0 - 8.0, 14.0, [180, 180, 180]);
534             return Some(pc.finish());
535         }
536 
537         let (rects, cw, ch) = self.layout();
538         let (ox, oy) = self.origin(cw, ch);
539         let want_dpi = {
540             let want = 72.0 * self.zoom * scale;
541             *DPI_BUCKETS
542                 .iter()
543                 .find(|&&b| want <= b as f64 * 1.01)
544                 .unwrap_or(DPI_BUCKETS.last().unwrap())
545         };
546 
547         let mut visible = Vec::new();
548         for (i, r) in rects.iter().enumerate() {
549             let rect = Rect {
550                 x: (ox + r.x * self.zoom) as f32,
551                 y: (oy + r.y * self.zoom) as f32,
552                 width: (r.w * self.zoom) as f32,
553                 height: (r.h * self.zoom) as f32,
554             };
555             if rect.y > size.height || rect.y + rect.height < 0.0 {
556                 continue;
557             }
558             visible.push((i, rect));
559         }
560         let doc = self.doc.take().unwrap();
561         for (i, rect) in &visible {
562             // White page ground: placeholder while rendering, and backing
563             // for images with transparency.
564             pc.quad(
565                 Rect { x: rect.x - 1.0, y: rect.y - 1.0, width: rect.width + 2.0, height: rect.height + 2.0 },
566                 [0.0, 0.0, 0.0, 0.35],
567             );
568             pc.quad(*rect, [0.97, 0.97, 0.97, 1.0]);
569             if let Some(r) = self.store.ensure(&doc, self.quarter_turns, *i, want_dpi) {
570                 pc.image(r.image, *rect, 1.0);
571             }
572         }
573         self.doc = Some(doc);
574 
575         // HUD: file name, page, zoom (top-left chip).
576         let doc = self.doc.as_ref().unwrap();
577         let name = doc.path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
578         let mut hud = name.to_string();
579         if doc.pages.len() > 1 {
580             hud.push_str(&format!("   ·   page {}/{}", self.current_page() + 1, doc.pages.len()));
581         } else if self.files.len() > 1 {
582             hud.push_str(&format!("   ·   {}/{}", self.file_idx + 1, self.files.len()));
583         }
584         hud.push_str(&format!("   ·   {:.0}%", self.zoom * 100.0));
585         // The HUD stands the root plate's inset off the window corner, its
586         // text the control text inset inside the box.
587         let inset = cce_ui::layout::root_plate_inset();
588         let text_in = cce_ui::layout::CONTROL_TEXT_INSET;
589         let w = 2.0 * text_in + hud.chars().count() as f32 * 6.6;
590         pc.quad(Rect { x: inset, y: inset, width: w, height: 24.0 }, [0.0, 0.0, 0.0, 0.45]);
591         pc.text(hud, inset + text_in, inset + 5.0, 12.0, [230, 230, 230]);
592 
593         Some(pc.finish())
594     }
595 
596     fn display_list_text(&self) -> bool {
597         true
598     }
599 
600     fn clear_color(&self) -> [f32; 4] {
601         [0.13, 0.13, 0.14, 1.0]
602     }
603 }
604 
605 fn main() {
606     env_logger::init();
607     cce_ui::engine::run::<PreviewApp>();
608 }