Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_pearlite/src/window.rs

29.8 KiB, 91 runs

created by r1870400018:59234, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Phase 3: a true native desktop window that opens a `.prl` and renders its pages with the CPU
2//! rasteriser, no webview.
3//!
4//! The window is [`winit`]'s cross-platform surface (x11/wayland, macOS, Windows) and the page pixels are
5//! blitted to it with [`softbuffer`]'s raw framebuffer -- so the very [`Pixmap`](crate::raster::RasterPage)
6//! the PNG path draws is what reaches the screen, at pixel parity. Pages stack vertically and fit the
7//! window width; the DPI each page rasters at is chosen from the window's physical width, so a HiDPI
8//! display (winit reports its scale factor, and the physical inner size already carries it) rasters
9//! sharper rather than larger. Navigation is wheel, PageUp/PageDown, arrows and Home/End; a resize
10//! re-fits the width and re-rasters.
11//!
12//! A document that carries an outline gets a contents sidebar on the left, the native counterpart of the
13//! web reader's contents rail: the heading tree from [`PearlDoc::outline`], each branch folded or
14//! unfolded by its caret, a click on a title jumping to that heading, and the heading the view is in
15//! marked. The toggle in the top-left corner, or the `t` key, shows and hides it. Titles are set in the
16//! embedded Libertinus faces through the same shaper and outline filler the pages use.
17//!
18//! This module is behind the default-off `gui` feature, the sole gate on the winit and softbuffer
19//! dependencies. House rule: the winit callback model cannot return an error, so a failure inside a
20//! handler is stored on the app and the loop is asked to exit; [`open`] inspects it after the loop and
21//! surfaces it as an [`Outcome`]. No `unwrap`, `?` or `unsafe` in this crate's own code.
22
23use crate::contents::Contents;
24use crate::raster::{
25 self,
26 RasterPage,
27};
28
29use oxedyne_fe2o3_austenite::emit::pearl::{
30 OutlineEntry,
31 PearlDoc,
32};
33use oxedyne_fe2o3_austenite::fonts;
34
35use oxedyne_fe2o3_core::prelude::*;
36use oxedyne_fe2o3_font::{
37 face::Role,
38 font::Font,
39 set::FontSet,
40 shape::Dir,
41};
42use oxedyne_fe2o3_graphics::colour::Rgba;
43use oxedyne_fe2o3_graphics::path::{
44 Bounds,
45 Path,
46 PathBuilder,
47 Pt,
48};
49use oxedyne_fe2o3_graphics::pixmap::Pixmap;
50use oxedyne_fe2o3_graphics::transform::Transform;
51
52use std::num::NonZeroU32;
53use std::rc::Rc;
54
55use winit::application::ApplicationHandler;
56use winit::event::{
57 ElementState,
58 KeyEvent,
59 MouseButton,
60 MouseScrollDelta,
61 WindowEvent,
62};
63use winit::event_loop::{
64 ActiveEventLoop,
65 ControlFlow,
66 EventLoop,
67};
68use winit::keyboard::{
69 Key,
70 NamedKey,
71};
72use winit::window::{
73 Window,
74 WindowId,
75};
76
77// A step for an arrow key and a wheel line, in logical-ish physical pixels; a page step is most of the
78// viewport height, keeping a sliver of overlap for continuity.
79const LINE_STEP: f64 = 64.0;
80const PAGE_FRACTION: f64 = 0.9;
81// The grey gutter between and around pages, in device pixels at 1x; it rides the scale factor.
82const GUTTER: f64 = 16.0;
83// The window's background and page gutter, as a 0x00RRGGBB softbuffer word.
84const GUTTER_RGB: u32 = 0x00303338;
85const GUTTER_INK: Rgba = Rgba::opaque(0x30, 0x33, 0x38); // the same, for painting into a pixmap
86
87// Contents sidebar geometry, in logical pixels; each rides the scale factor.
88const SIDE_W: f64 = 280.0; // open width, capped at SIDE_MAX of the window
89const SIDE_MAX: f64 = 0.6;
90const HEAD_H: f64 = 44.0; // header band holding the toggle and the label
91const ROW_H: f64 = 26.0;
92const INDENT: f64 = 14.0; // per nesting level
93const CARET_W: f64 = 16.0;
94const PAD: f64 = 8.0;
95const BTN: f64 = 28.0; // the toggle, square, at (PAD, PAD) whether the sidebar is open or not
96const NUM_GAP: f64 = 6.0; // between a heading's number and its title
97const JUMP_CLEAR: f64 = 24.0; // clearance left above a heading jumped to
98
99// Contents sidebar palette, after the web reader's rail.
100const SIDE_BG: Rgba = Rgba::opaque(0xfa, 0xfa, 0xfa);
101const SIDE_RULE: Rgba = Rgba::opaque(0xe5, 0xe7, 0xeb);
102const TEXT_MAIN: Rgba = Rgba::opaque(0x11, 0x18, 0x27);
103const TEXT_DEEP: Rgba = Rgba::opaque(0x37, 0x41, 0x51); // level 3 and below
104const TEXT_NUM: Rgba = Rgba::opaque(0x9c, 0xa3, 0xaf);
105const TEXT_HEAD: Rgba = Rgba::opaque(0x6b, 0x72, 0x80);
106const TEXT_DEAD: Rgba = Rgba::opaque(0xb9, 0x1c, 0x1c); // a heading the ledger never placed
107const CUR_BG: Rgba = Rgba::opaque(0xdb, 0xea, 0xfe);
108const CUR_TEXT: Rgba = Rgba::opaque(0x1d, 0x4e, 0xd8);
109const CARET_INK: Rgba = Rgba::opaque(0x9c, 0xa3, 0xaf);
110const BTN_ON: Rgba = Rgba::opaque(0x25, 0x63, 0xeb);
111const BTN_OFF: Rgba = Rgba::opaque(0xff, 0xff, 0xff);
112const BTN_EDGE: Rgba = Rgba::opaque(0xd1, 0xd5, 0xdb);
113const BTN_BARS: Rgba = Rgba::opaque(0x37, 0x41, 0x51);
114
115/// Opens `doc` in a native window titled `title`, returning when the window closes. Blocks the calling
116/// thread for the lifetime of the window, as a desktop reader's main loop does.
117pub fn open(doc: PearlDoc, title: String) -> Outcome<()> {
118 let page_count = res!(doc.page_count());
119 let mut sizes = Vec::with_capacity(page_count);
120 for i in 0..page_count {
121 sizes.push(res!(doc.page_size(i)));
122 }
123
124 let contents = Contents::new(res!(doc.outline()));
125 // The sidebar's faces are parsed only for a document that has a sidebar to set.
126 let faces = if contents.is_empty() { None } else { Some(res!(fonts::libertinus())) };
127
128 // An optional headless capture: render the first frame at a given scroll offset, save it as a PNG and
129 // exit. This is how the reader is screenshotted under `xvfb-run`, and it doubles as a smoke test that
130 // the window opens, rasters a frame and presents it cleanly. Off unless `PEARLITE_CAPTURE` is set.
131 // Three companions shape that frame: `PEARLITE_CAPTURE_CONTENTS=closed` starts with the sidebar
132 // hidden, `PEARLITE_CAPTURE_FOLD=i,j` folds those outline entries, and `PEARLITE_CAPTURE_JUMP=n`
133 // jumps to outline entry `n` exactly as a click on its row does.
134 let capture = std::env::var("PEARLITE_CAPTURE").ok().map(|path| Capture {
135 path,
136 scroll: std::env::var("PEARLITE_CAPTURE_SCROLL").ok()
137 .and_then(|s| s.parse::<f64>().ok())
138 .unwrap_or(0.0),
139 });
140 let (toc_open, jump, folds) = match capture {
141 Some(_) => (
142 std::env::var("PEARLITE_CAPTURE_CONTENTS").map(|v| v != "closed").unwrap_or(true),
143 std::env::var("PEARLITE_CAPTURE_JUMP").ok().and_then(|s| s.parse::<usize>().ok()),
144 std::env::var("PEARLITE_CAPTURE_FOLD").ok()
145 .map(|s| s.split(',').filter_map(|t| t.trim().parse::<usize>().ok()).collect())
146 .unwrap_or_else(Vec::new),
147 ),
148 None => (true, None, Vec::new()),
149 };
150
151 let event_loop = res!(EventLoop::new(), IO, System, Init);
152 event_loop.set_control_flow(ControlFlow::Wait);
153
154 let mut app = Reader {
155 doc,
156 title,
157 sizes,
158 cache: (0..page_count).map(|_| None).collect(),
159 cache_width: 0,
160 scroll: capture.as_ref().map(|c| c.scroll).unwrap_or(0.0),
161 window: None,
162 surface: None,
163 capture,
164 captured: false,
165 result: Ok(()),
166 contents,
167 faces,
168 toc_open,
169 toc_scroll: 0.0,
170 toc_version: 0,
171 sidebar: None,
172 layout: Layout::default(),
173 cursor: (0.0, 0.0),
174 jump,
175 };
176 for idx in folds {
177 if app.contents.toggle(idx) {
178 app.toc_version += 1;
179 }
180 }
181
182 res!(event_loop.run_app(&mut app), IO, System);
183 app.result
184}
185
186/// A pending headless capture: where to write the PNG, and the scroll offset to render it at.
187struct Capture {
188 path: String,
189 scroll: f64,
190}
191
192/// What the last frame was laid out at, for hit-testing a click against what is on screen.
193#[derive(Clone, Copy, Debug, Default)]
194struct Layout {
195 side: usize, // sidebar width, device pixels, zero when hidden
196 scale: f64,
197}
198
199/// Everything the sidebar's pixels depend on; the cached sidebar is redrawn only when this changes.
200#[derive(Clone, Copy, Debug, PartialEq)]
201struct SideKey {
202 w: usize,
203 h: usize,
204 scale: f64,
205 scroll: f64,
206 current: Option<usize>,
207 version: u64,
208}
209
210type ReaderSurface = softbuffer::Surface<Rc<Window>, Rc<Window>>;
211
212struct Reader {
213 doc: PearlDoc,
214 title: String,
215 sizes: Vec<(usize, usize)>, // each page's media box, whole points
216 cache: Vec<Option<RasterPage>>, // rasterised pages at `cache_width`
217 cache_width: usize, // the page-area width the cache was rendered for
218 scroll: f64, // vertical scroll, device pixels from the top of the stack
219 window: Option<Rc<Window>>,
220 surface: Option<ReaderSurface>,
221 capture: Option<Capture>,
222 captured: bool,
223 result: Outcome<()>,
224 contents: Contents, // the outline tree, empty for a document without one
225 faces: Option<FontSet>, // the sidebar's faces, loaded only when there is a sidebar
226 toc_open: bool,
227 toc_scroll: f64, // sidebar list scroll, device pixels
228 toc_version: u64, // bumped on every fold, so the cached sidebar is redrawn
229 sidebar: Option<(SideKey, Pixmap)>,
230 layout: Layout,
231 cursor: (f64, f64), // last pointer position, device pixels
232 jump: Option<usize>, // an outline entry to jump to at the next frame
233}
234
235impl Reader {
236 /// Records the first failure and asks the loop to exit; later failures are dropped, the first being
237 /// the one worth reporting.
238 fn fail(&mut self, event_loop: &ActiveEventLoop, e: Error<ErrTag>) {
239 if self.result.is_ok() {
240 self.result = Err(e);
241 }
242 event_loop.exit();
243 }
244
245 fn redraw(&self) {
246 if let Some(w) = &self.window {
247 w.request_redraw();
248 }
249 }
250
251 /// The device height, in pixels, a page occupies when fitted to width `w`.
252 fn page_device_height(&self, idx: usize, w: usize) -> usize {
253 let (pw, ph) = self.sizes[idx];
254 if pw == 0 {
255 return 0;
256 }
257 let s = (w as f32) / (pw as f32);
258 (((ph as f32) * s).ceil() as usize).max(1)
259 }
260
261 /// The DPI a page rasters at to fit width `w`.
262 fn page_dpi(&self, idx: usize, w: usize) -> f32 {
263 let (pw, _) = self.sizes[idx];
264 if pw == 0 {
265 return raster::DEFAULT_DPI;
266 }
267 raster::DEFAULT_DPI * (w as f32) / (pw as f32)
268 }
269
270 /// The total height of the page stack at width `w`, including a gutter above every page and below the
271 /// last.
272 fn stack_height(&self, w: usize, gutter: f64) -> f64 {
273 let mut total = gutter;
274 for idx in 0..self.sizes.len() {
275 total += self.page_device_height(idx, w) as f64 + gutter;
276 }
277 total
278 }
279
280 /// Where a heading sits in the page stack at width `w`, in device pixels from the stack's top: its
281 /// page's top plus its own y scaled as the page is. `None` for a heading the ledger never placed, or
282 /// one naming a page the document does not have.
283 fn entry_y(&self, e: &OutlineEntry, w: usize, gutter: f64) -> Option<f64> {
284 let idx = match e.page {
285 Some(p) if p >= 1 && (p as usize) <= self.sizes.len() => (p as usize) - 1,
286 _ => return None,
287 };
288 let mut top = gutter;
289 for i in 0..idx {
290 top += self.page_device_height(i, w) as f64 + gutter;
291 }
292 let pw = self.sizes[idx].0;
293 let s = if pw == 0 { 0.0 } else { (w as f64) / (pw as f64) };
294 Some(top + e.y.map(|y| y.to_pt()).unwrap_or(0.0) * s)
295 }
296
297 /// The sidebar's width in a window `w` device pixels wide: nothing without an outline or while
298 /// hidden.
299 fn sidebar_width(&self, w: usize, scale: f64) -> usize {
300 if self.contents.is_empty() || !self.toc_open {
301 return 0;
302 }
303 ((SIDE_W * scale).round() as usize).min(((w as f64) * SIDE_MAX) as usize)
304 }
305
306 /// Renders and presents one frame. Clears the cache when the page area's width changed, clamps the
307 /// scrolls, paints the gutter, blits every visible page, then lays the sidebar and toggle over them.
308 fn draw(&mut self, event_loop: &ActiveEventLoop) {
309 let window = match &self.window {
310 Some(w) => w.clone(),
311 None => return,
312 };
313 let size = window.inner_size();
314 let (w, h) = (size.width as usize, size.height as usize);
315 if w == 0 || h == 0 {
316 return;
317 }
318 let scale = window.scale_factor();
319 let gutter = GUTTER * scale;
320 let side = self.sidebar_width(w, scale);
321 let area = w - side; // the page column's width
322 if area == 0 {
323 return;
324 }
325 self.layout = Layout { side, scale };
326
327 if area != self.cache_width {
328 for slot in self.cache.iter_mut() {
329 *slot = None;
330 }
331 self.cache_width = area;
332 }
333
334 // A jump asked for before the geometry was known lands now.
335 if let Some(idx) = self.jump.take() {
336 if let Some(y) = self.contents.entries().get(idx).and_then(|e| self.entry_y(e, area, gutter)) {
337 self.scroll = y - JUMP_CLEAR * scale;
338 }
339 }
340
341 // Clamp the scroll now the geometry is known.
342 let total = self.stack_height(area, gutter);
343 let max_scroll = (total - h as f64).max(0.0);
344 self.scroll = self.scroll.min(max_scroll).max(0.0);
345
346 // Rasterise every visible page up front, so the borrow of the surface buffer below holds nothing
347 // else of `self` mutably.
348 let mut placements: Vec<(usize, i64)> = Vec::new(); // (page index, top y on screen)
349 let mut y = gutter;
350 for idx in 0..self.sizes.len() {
351 let ph = self.page_device_height(idx, area) as f64;
352 let top = y - self.scroll;
353 if top + ph >= 0.0 && top < h as f64 {
354 if self.cache[idx].is_none() {
355 let dpi = self.page_dpi(idx, area);
356 match raster::render_page_to_pixmap(&self.doc, idx, dpi) {
357 Ok(page) => self.cache[idx] = Some(page),
358 Err(e) => { self.fail(event_loop, e); return; },
359 }
360 }
361 placements.push((idx, top.round() as i64));
362 }
363 y += ph + gutter;
364 }
365
366 // The sidebar, redrawn only when something it shows has changed, and the toggle floating over the
367 // pages while the sidebar is hidden.
368 let mut toggle: Option<Pixmap> = None;
369 if !self.contents.is_empty() {
370 let outcome = if side > 0 {
371 self.refresh_sidebar(side, h, scale, area, gutter)
372 } else {
373 self.sidebar = None;
374 self.floating_toggle(scale).map(|pm| { toggle = Some(pm); })
375 };
376 if let Err(e) = outcome {
377 self.fail(event_loop, e);
378 return;
379 }
380 }
381 let mut overlays: Vec<(&Pixmap, usize, usize)> = Vec::new(); // (pixels, left, top)
382 if let Some((_, pm)) = &self.sidebar {
383 if side > 0 {
384 overlays.push((pm, 0, 0));
385 }
386 }
387 let at = (PAD * scale).round() as usize;
388 if let Some(pm) = &toggle {
389 overlays.push((pm, at, at));
390 }
391
392 // Disjoint field borrows: the surface, the page cache, the capture and its flag are separate fields,
393 // so the framebuffer work happens in an associated function that never re-borrows all of `self` --
394 // which is what lets an error surface through the return rather than a `self.fail` call inside the
395 // live buffer borrow.
396 let outcome = Self::present_frame(
397 self.surface.as_mut(),
398 &self.cache,
399 &self.capture,
400 &mut self.captured,
401 w, h,
402 side,
403 &placements,
404 &overlays,
405 );
406 match outcome {
407 Ok(true) => event_loop.exit(),
408 Ok(false) => {},
409 Err(e) => self.fail(event_loop, e),
410 }
411 }
412
413 /// The toggle drawn on its own, to float over the pages while the sidebar is hidden.
414 fn floating_toggle(&self, scale: f64) -> Outcome<Pixmap> {
415 let bs = (BTN * scale).round().max(1.0) as usize;
416 let mut pm = res!(Pixmap::filled(bs, bs, GUTTER_INK));
417 res!(paint_toggle(&mut pm, 0.0, 0.0, bs as f32, false));
418 Ok(pm)
419 }
420
421 /// Redraws the cached sidebar when its key has changed: the window, the list scroll, the current
422 /// heading or a fold.
423 fn refresh_sidebar(
424 &mut self,
425 side: usize,
426 h: usize,
427 scale: f64,
428 area: usize,
429 gutter: f64,
430 )
431 -> Outcome<()>
432 {
433 // Clamp the list scroll to the rows there are.
434 let rows = self.contents.visible().len() as f64;
435 let content = (HEAD_H + rows * ROW_H + PAD) * scale;
436 self.toc_scroll = self.toc_scroll.min((content - h as f64).max(0.0)).max(0.0);
437
438 let at = self.scroll + JUMP_CLEAR * scale + 1.0;
439 let current = self.contents.current(|e| self.entry_y(e, area, gutter), at);
440 let key = SideKey {
441 w: side,
442 h,
443 scale,
444 scroll: self.toc_scroll,
445 current,
446 version: self.toc_version,
447 };
448 if let Some((k, _)) = &self.sidebar {
449 if *k == key {
450 return Ok(());
451 }
452 }
453 let pm = res!(self.render_sidebar(&key));
454 self.sidebar = Some((key, pm));
455 Ok(())
456 }
457
458 /// Paints the sidebar: the visible rows of the contents tree, the current heading's row marked, then
459 /// the header band with the toggle and its label laid over any row scrolled beneath it.
460 fn render_sidebar(&self, key: &SideKey) -> Outcome<Pixmap> {
461 let faces = res!(self.faces.as_ref().ok_or_else(|| err!(
462 "The contents sidebar was drawn without its faces loaded."; Bug, Missing)));
463 let s = key.scale as f32;
464 let w = key.w as f32;
465 let h = key.h as f32;
466 let rule = s.max(1.0).round();
467 let head = HEAD_H as f32 * s;
468 let row_h = ROW_H as f32 * s;
469 let pad = PAD as f32 * s;
470 let mut pm = res!(Pixmap::filled(key.w, key.h, SIDE_BG));
471 let list = Bounds::new(0.0, head, w - rule, h);
472
473 let mark = key.current.map(|c| self.contents.shown_for(c));
474 for (r, &idx) in self.contents.visible().iter().enumerate() {
475 let top = head + (r as f32) * row_h - key.scroll as f32;
476 if top + row_h < head || top > h {
477 continue;
478 }
479 let e = &self.contents.entries()[idx];
480 let is_mark = mark == Some(idx);
481 if is_mark {
482 res!(pm.fill_bounds(Bounds::new(pad * 0.5, top, w - rule - pad * 0.5, top + row_h),
483 CUR_BG, Some(list)));
484 }
485 let x0 = pad + (self.contents.depth(idx) as f32) * INDENT as f32 * s;
486 let mid = top + row_h * 0.5;
487 if self.contents.has_children(idx) {
488 res!(paint_caret(&mut pm, x0 + CARET_W as f32 * s * 0.5, mid, s,
489 self.contents.is_open(idx), list));
490 }
491 let (role, size, ink) = match e.level {
492 0 | 1 => (Role::Bold, 13.5, TEXT_MAIN),
493 2 => (Role::Body, 13.0, TEXT_MAIN),
494 _ => (Role::Body, 12.5, TEXT_DEEP),
495 };
496 let ink = if e.page.is_none() { TEXT_DEAD } else if is_mark { CUR_TEXT } else { ink };
497 let font = faces.get(role);
498 let size = size * s;
499 let m = res!(font.metrics(size));
500 let base = mid + (m.ascent - m.descent) * 0.5;
501 let right = w - rule - pad;
502 let mut x = x0 + CARET_W as f32 * s;
503 if !e.number.is_empty() {
504 let num = faces.get(Role::Body);
505 x += res!(paint_text(&mut pm, num, size, &e.number, x, base, right - x, TEXT_NUM, list));
506 x += NUM_GAP as f32 * s;
507 }
508 res!(paint_text(&mut pm, font, size, &e.title, x, base, right - x, ink, list));
509 }
510
511 // The header band, over whatever row scrolled up beneath it.
512 let whole = Bounds::new(0.0, 0.0, w, h);
513 res!(pm.fill_bounds(Bounds::new(0.0, 0.0, w, head), SIDE_BG, None));
514 res!(pm.fill_bounds(Bounds::new(0.0, head - rule, w, head), SIDE_RULE, None));
515 res!(pm.fill_bounds(Bounds::new(w - rule, 0.0, w, h), SIDE_RULE, None));
516 let b = pad;
517 let bs = BTN as f32 * s;
518 res!(paint_toggle(&mut pm, b, b, bs, true));
519 let label = faces.get(Role::Bold);
520 let lsize = 11.0 * s;
521 let lm = res!(label.metrics(lsize));
522 let lbase = b + bs * 0.5 + (lm.ascent - lm.descent) * 0.5;
523 let lx = b + bs + 10.0 * s;
524 res!(paint_text(&mut pm, label, lsize, "CONTENTS", lx, lbase, w - rule - pad - lx, TEXT_HEAD, whole));
525 Ok(pm)
526 }
527
528 /// Resizes the surface, paints the gutter, blits the visible pages into the column right of the
529 /// sidebar, lays the overlays on top, optionally captures the frame, and presents it. Returns `true`
530 /// when the loop should exit (a headless capture is done). Takes its inputs as separate field borrows
531 /// so no `&mut self` overlaps the live framebuffer borrow.
532 fn present_frame(
533 surface: Option<&mut ReaderSurface>,
534 cache: &[Option<RasterPage>],
535 capture: &Option<Capture>,
536 captured: &mut bool,
537 w: usize,
538 h: usize,
539 left: usize,
540 placements: &[(usize, i64)],
541 overlays: &[(&Pixmap, usize, usize)],
542 ) -> Outcome<bool> {
543 let surface = match surface {
544 Some(s) => s,
545 None => return Ok(false),
546 };
547 let nz_w = res!(NonZeroU32::new(w as u32).ok_or_else(|| err!(
548 "A frame cannot have zero width."; Bug, Invalid)));
549 let nz_h = res!(NonZeroU32::new(h as u32).ok_or_else(|| err!(
550 "A frame cannot have zero height."; Bug, Invalid)));
551 if let Err(e) = surface.resize(nz_w, nz_h) {
552 return Err(err!("Resizing the framebuffer to {}x{} failed: {}.", w, h, e; IO, System));
553 }
554 let mut buffer = match surface.buffer_mut() {
555 Ok(b) => b,
556 Err(e) => return Err(err!("Acquiring the framebuffer failed: {}.", e; IO, System)),
557 };
558
559 for px in buffer.iter_mut() {
560 *px = GUTTER_RGB;
561 }
562
563 for (idx, top) in placements {
564 if let Some(page) = &cache[*idx] {
565 blit(&mut buffer, w, h, page.pixmap.data(), page.width_px, page.height_px,
566 left, *top);
567 }
568 }
569 for (pm, x, y) in overlays {
570 blit(&mut buffer, w, h, pm.data(), pm.width(), pm.height(), *x, *y as i64);
571 }
572
573 // A headless capture takes the finished frame before `present` consumes the buffer.
574 if let Some(cap) = capture {
575 if !*captured {
576 res!(save_frame(&buffer, w, h, &cap.path));
577 *captured = true;
578 }
579 }
580
581 if let Err(e) = buffer.present() {
582 return Err(err!("Presenting the framebuffer failed: {}.", e; IO, System));
583 }
584
585 Ok(capture.is_some() && *captured)
586 }
587
588 /// Applies a scroll delta and asks for a redraw.
589 fn scroll_by(&mut self, delta: f64) {
590 self.scroll += delta;
591 self.redraw();
592 }
593
594 /// Jumps to the top or the bottom of the stack.
595 fn scroll_to(&mut self, top: bool) {
596 self.scroll = if top { 0.0 } else { f64::MAX };
597 self.redraw();
598 }
599
600 /// A page step: most of the viewport height, in the given direction (`+1` down, `-1` up).
601 fn page_step(&mut self, dir: f64) {
602 let step = self.window.as_ref()
603 .map(|w| w.inner_size().height as f64 * PAGE_FRACTION)
604 .unwrap_or(LINE_STEP);
605 self.scroll_by(dir * step);
606 }
607
608 /// Shows or hides the contents sidebar. The page column changes width, so the pages re-fit.
609 fn toggle_contents(&mut self) {
610 if self.contents.is_empty() {
611 return;
612 }
613 self.toc_open = !self.toc_open;
614 self.redraw();
615 }
616
617 /// A left click: the toggle, a caret folding its branch, or a title jumping to its heading.
618 fn click(&mut self) {
619 if self.contents.is_empty() {
620 return;
621 }
622 let Layout { side, scale, .. } = self.layout;
623 let (cx, cy) = self.cursor;
624 let b = PAD * scale;
625 let bs = BTN * scale;
626 if cx >= b && cx < b + bs && cy >= b && cy < b + bs {
627 self.toggle_contents();
628 return;
629 }
630 let head = HEAD_H * scale;
631 if side == 0 || cx >= side as f64 || cy < head {
632 return;
633 }
634 let row = ((cy - head + self.toc_scroll) / (ROW_H * scale)).floor();
635 if row < 0.0 {
636 return;
637 }
638 let idx = match self.contents.visible().get(row as usize) {
639 Some(&i) => i,
640 None => return,
641 };
642 let x0 = PAD * scale + (self.contents.depth(idx) as f64) * INDENT * scale;
643 if self.contents.has_children(idx) && cx >= x0 && cx < x0 + CARET_W * scale {
644 if self.contents.toggle(idx) {
645 self.toc_version += 1;
646 }
647 } else if self.contents.entries()[idx].page.is_some() {
648 self.jump = Some(idx);
649 }
650 self.redraw();
651 }
652}
653
654impl ApplicationHandler for Reader {
655 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
656 if self.window.is_some() {
657 return; // already have a window; a second resume is not a second window
658 }
659 // Wide enough for the sidebar beside a full page when there is an outline.
660 let width = if self.contents.is_empty() { 900.0 } else { 900.0 + SIDE_W };
661 let attrs = Window::default_attributes()
662 .with_title(self.title.clone())
663 .with_inner_size(winit::dpi::LogicalSize::new(width, 1120.0));
664 let window = match event_loop.create_window(attrs) {
665 Ok(w) => Rc::new(w),
666 Err(e) => { self.fail(event_loop, err!("Creating the reader window failed: {}.", e; IO, System, Init)); return; },
667 };
668 let context = match softbuffer::Context::new(window.clone()) {
669 Ok(c) => c,
670 Err(e) => { self.fail(event_loop, err!("Creating the softbuffer context failed: {}.", e; IO, System, Init)); return; },
671 };
672 let surface = match softbuffer::Surface::new(&context, window.clone()) {
673 Ok(s) => s,
674 Err(e) => { self.fail(event_loop, err!("Creating the framebuffer surface failed: {}.", e; IO, System, Init)); return; },
675 };
676 window.request_redraw();
677 self.window = Some(window);
678 self.surface = Some(surface);
679 }
680
681 fn window_event(
682 &mut self,
683 event_loop: &ActiveEventLoop,
684 _id: WindowId,
685 event: WindowEvent,
686 ) {
687 match event {
688 WindowEvent::CloseRequested => {
689 event_loop.exit();
690 },
691 WindowEvent::Resized(_) => {
692 self.redraw();
693 },
694 WindowEvent::ScaleFactorChanged { .. } => {
695 // The physical inner size already carries the new factor; a redraw re-fits and re-rasters.
696 self.cache_width = 0; // force a re-raster at the new physical width
697 self.redraw();
698 },
699 WindowEvent::RedrawRequested => {
700 self.draw(event_loop);
701 },
702 WindowEvent::CursorMoved { position, .. } => {
703 self.cursor = (position.x, position.y);
704 },
705 WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
706 self.click();
707 },
708 WindowEvent::MouseWheel { delta, .. } => {
709 let dy = match delta {
710 MouseScrollDelta::LineDelta(_, y) => (y as f64) * LINE_STEP,
711 MouseScrollDelta::PixelDelta(p) => p.y,
712 };
713 // Wheel up (positive) scrolls the content up, toward the top: a smaller offset. Over the
714 // sidebar it scrolls the contents list instead of the pages.
715 if self.layout.side > 0 && self.cursor.0 < self.layout.side as f64 {
716 self.toc_scroll -= dy;
717 self.redraw();
718 } else {
719 self.scroll_by(-dy);
720 }
721 },
722 WindowEvent::KeyboardInput {
723 event: KeyEvent { logical_key, state: ElementState::Pressed, .. },
724 ..
725 } => {
726 match logical_key {
727 Key::Named(NamedKey::PageDown) => self.page_step(1.0),
728 Key::Named(NamedKey::PageUp) => self.page_step(-1.0),
729 Key::Named(NamedKey::ArrowDown) => self.scroll_by(LINE_STEP),
730 Key::Named(NamedKey::ArrowUp) => self.scroll_by(-LINE_STEP),
731 Key::Named(NamedKey::Space) => self.page_step(1.0),
732 Key::Named(NamedKey::Home) => self.scroll_to(true),
733 Key::Named(NamedKey::End) => self.scroll_to(false),
734 Key::Named(NamedKey::Escape) => event_loop.exit(),
735 Key::Character(c) if c == "t" => self.toggle_contents(),
736 _ => {},
737 }
738 },
739 _ => {},
740 }
741 }
742}
743
744/// Sets `text` in `font` at `size` device pixels on the baseline `base` from `x`, clipped to `clip`,
745/// ending in an ellipsis when it would run past `max_w`. Returns the width it took.
746fn paint_text(
747 pm: &mut Pixmap,
748 font: &Font,
749 size: f32,
750 text: &str,
751 x: f32,
752 base: f32,
753 max_w: f32,
754 ink: Rgba,
755 clip: Bounds,
756)
757 -> Outcome<f32>
758{
759 if max_w <= 0.0 {
760 return Ok(0.0);
761 }
762 let run = res!(font.shape(text, size, Dir::Ltr));
763 if run.advance <= max_w {
764 for g in &run.glyphs {
765 res!(paint_glyph(pm, font, g.face, g.id, size, x + g.x, base - g.y, ink, clip));
766 }
767 return Ok(run.advance);
768 }
769 let ell = res!(font.shape("\u{2026}", size, Dir::Ltr));
770 let limit = max_w - ell.advance;
771 let mut pen = 0.0;
772 for g in run.glyphs.iter().take_while(|g| g.x + g.adv <= limit) {
773 res!(paint_glyph(pm, font, g.face, g.id, size, x + g.x, base - g.y, ink, clip));
774 pen = g.x + g.adv;
775 }
776 for g in &ell.glyphs {
777 res!(paint_glyph(pm, font, g.face, g.id, size, x + pen + g.x, base - g.y, ink, clip));
778 }
779 Ok(pen + ell.advance)
780}
781
782/// Fills one glyph's outline with its origin at (`x`, `y`). The outline is font-frame, y up; the pixmap
783/// is y down, so it is flipped first, exactly as the SVG emitter places a glyph.
784fn paint_glyph(
785 pm: &mut Pixmap,
786 font: &Font,
787 face: u8,
788 id: u32,
789 size: f32,
790 x: f32,
791 y: f32,
792 ink: Rgba,
793 clip: Bounds,
794)
795 -> Outcome<()>
796{
797 let path = res!(font.outline(face, id, size));
798 if path.is_empty() {
799 return Ok(()); // a space: an advance and no ink
800 }
801 let t = Transform::scale(1.0, -1.0).then(&Transform::translate(x, y));
802 pm.fill_path(&path, &t, ink, Some(clip))
803}
804
805/// A branch's caret centred on (`cx`, `cy`): pointing down while open, right while folded.
806fn paint_caret(pm: &mut Pixmap, cx: f32, cy: f32, s: f32, open: bool, clip: Bounds) -> Outcome<()> {
807 let (a, b, c) = if open {
808 (Pt::new(cx - 4.0 * s, cy - 2.0 * s), Pt::new(cx + 4.0 * s, cy - 2.0 * s), Pt::new(cx, cy + 3.0 * s))
809 } else {
810 (Pt::new(cx - 2.0 * s, cy - 4.0 * s), Pt::new(cx - 2.0 * s, cy + 4.0 * s), Pt::new(cx + 3.0 * s, cy))
811 };
812 let mut pb = PathBuilder::new();
813 pb.move_to(a);
814 pb.line_to(b);
815 pb.line_to(c);
816 pb.close();
817 let path: Path = res!(pb.finish());
818 pm.fill_path(&path, &Transform::IDENTITY, CARET_INK, Some(clip))
819}
820
821/// The contents toggle, a `bs`-square button at (`x`, `y`) bearing three bars: filled blue while the
822/// sidebar is open, as the web reader's pressed toggle is, and white with an edge while it is hidden.
823fn paint_toggle(pm: &mut Pixmap, x: f32, y: f32, bs: f32, on: bool) -> Outcome<()> {
824 let r = bs * 0.18;
825 let edge = (bs / 28.0).max(1.0);
826 let (face, bars) = if on { (BTN_ON, BTN_OFF) } else { (BTN_OFF, BTN_BARS) };
827 if !on {
828 let outer = res!(Path::round_rect(Bounds::new(x, y, x + bs, y + bs), r));
829 res!(pm.fill_path(&outer, &Transform::IDENTITY, BTN_EDGE, None));
830 }
831 let inner = if on {
832 Bounds::new(x, y, x + bs, y + bs)
833 } else {
834 Bounds::new(x + edge, y + edge, x + bs - edge, y + bs - edge)
835 };
836 let body = res!(Path::round_rect(inner, (r - edge).max(0.0)));
837 res!(pm.fill_path(&body, &Transform::IDENTITY, face, None));
838 let (bw, bh) = (bs * 0.5, (bs / 14.0).max(1.0));
839 let bx = x + (bs - bw) * 0.5;
840 for k in [-1.0f32, 0.0, 1.0] {
841 let cy = y + bs * 0.5 + k * bs * 0.18;
842 res!(pm.fill_bounds(Bounds::new(bx, cy - bh * 0.5, bx + bw, cy + bh * 0.5), bars, None));
843 }
844 Ok(())
845}
846
847/// Blits RGBA pixels `src_w` by `src_h` into the framebuffer at (`left`, `top`), clipping to the window.
848fn blit(
849 buffer: &mut [u32],
850 w: usize,
851 h: usize,
852 data: &[u8],
853 src_w: usize,
854 src_h: usize,
855 left: usize,
856 top: i64,
857) {
858 if left >= w {
859 return;
860 }
861 let cols = src_w.min(w - left);
862 for py in 0..src_h {
863 let sy = top + py as i64;
864 if sy < 0 {
865 continue;
866 }
867 let sy = sy as usize;
868 if sy >= h {
869 break;
870 }
871 let row = sy * w + left;
872 let src_row = py * src_w * 4;
873 for px in 0..cols {
874 let si = src_row + px * 4;
875 // Every source here is opaque -- a page on its white ground, the sidebar on its own -- so alpha
876 // is ignored: straight RGB into 0x00RRGGBB.
877 let r = data[si] as u32;
878 let g = data[si + 1] as u32;
879 let b = data[si + 2] as u32;
880 buffer[row + px] = (r << 16) | (g << 8) | b;
881 }
882 }
883}
884
885/// Encodes the finished framebuffer to a PNG at `path`, for the headless capture path.
886fn save_frame(buffer: &[u32], w: usize, h: usize, path: &str) -> Outcome<()> {
887 let mut rgba = Vec::with_capacity(w * h * 4);
888 for px in buffer {
889 rgba.push(((px >> 16) & 0xff) as u8);
890 rgba.push(((px >> 8) & 0xff) as u8);
891 rgba.push((px & 0xff) as u8);
892 rgba.push(255);
893 }
894 let pm = res!(Pixmap::from_data(w, h, rgba));
895 let png = res!(pm.to_png());
896 res!(std::fs::write(path, &png), IO, File, Write);
897 Ok(())
898}