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 }