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

src/main.rs (22.9K)

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