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 }