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 | |
| 23 | use crate::contents::Contents; |
| 24 | use crate::raster::{ |
| 25 | self, |
| 26 | RasterPage, |
| 27 | }; |
| 28 | |
| 29 | use oxedyne_fe2o3_austenite::emit::pearl::{ |
| 30 | OutlineEntry, |
| 31 | PearlDoc, |
| 32 | }; |
| 33 | use oxedyne_fe2o3_austenite::fonts; |
| 34 | |
| 35 | use oxedyne_fe2o3_core::prelude::*; |
| 36 | use oxedyne_fe2o3_font::{ |
| 37 | face::Role, |
| 38 | font::Font, |
| 39 | set::FontSet, |
| 40 | shape::Dir, |
| 41 | }; |
| 42 | use oxedyne_fe2o3_graphics::colour::Rgba; |
| 43 | use oxedyne_fe2o3_graphics::path::{ |
| 44 | Bounds, |
| 45 | Path, |
| 46 | PathBuilder, |
| 47 | Pt, |
| 48 | }; |
| 49 | use oxedyne_fe2o3_graphics::pixmap::Pixmap; |
| 50 | use oxedyne_fe2o3_graphics::transform::Transform; |
| 51 | |
| 52 | use std::num::NonZeroU32; |
| 53 | use std::rc::Rc; |
| 54 | |
| 55 | use winit::application::ApplicationHandler; |
| 56 | use winit::event::{ |
| 57 | ElementState, |
| 58 | KeyEvent, |
| 59 | MouseButton, |
| 60 | MouseScrollDelta, |
| 61 | WindowEvent, |
| 62 | }; |
| 63 | use winit::event_loop::{ |
| 64 | ActiveEventLoop, |
| 65 | ControlFlow, |
| 66 | EventLoop, |
| 67 | }; |
| 68 | use winit::keyboard::{ |
| 69 | Key, |
| 70 | NamedKey, |
| 71 | }; |
| 72 | use 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. |
| 79 | const LINE_STEP: f64 = 64.0; |
| 80 | const PAGE_FRACTION: f64 = 0.9; |
| 81 | // The grey gutter between and around pages, in device pixels at 1x; it rides the scale factor. |
| 82 | const GUTTER: f64 = 16.0; |
| 83 | // The window's background and page gutter, as a 0x00RRGGBB softbuffer word. |
| 84 | const GUTTER_RGB: u32 = 0x00303338; |
| 85 | const 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. |
| 88 | const SIDE_W: f64 = 280.0; // open width, capped at SIDE_MAX of the window |
| 89 | const SIDE_MAX: f64 = 0.6; |
| 90 | const HEAD_H: f64 = 44.0; // header band holding the toggle and the label |
| 91 | const ROW_H: f64 = 26.0; |
| 92 | const INDENT: f64 = 14.0; // per nesting level |
| 93 | const CARET_W: f64 = 16.0; |
| 94 | const PAD: f64 = 8.0; |
| 95 | const BTN: f64 = 28.0; // the toggle, square, at (PAD, PAD) whether the sidebar is open or not |
| 96 | const NUM_GAP: f64 = 6.0; // between a heading's number and its title |
| 97 | const JUMP_CLEAR: f64 = 24.0; // clearance left above a heading jumped to |
| 98 | |
| 99 | // Contents sidebar palette, after the web reader's rail. |
| 100 | const SIDE_BG: Rgba = Rgba::opaque(0xfa, 0xfa, 0xfa); |
| 101 | const SIDE_RULE: Rgba = Rgba::opaque(0xe5, 0xe7, 0xeb); |
| 102 | const TEXT_MAIN: Rgba = Rgba::opaque(0x11, 0x18, 0x27); |
| 103 | const TEXT_DEEP: Rgba = Rgba::opaque(0x37, 0x41, 0x51); // level 3 and below |
| 104 | const TEXT_NUM: Rgba = Rgba::opaque(0x9c, 0xa3, 0xaf); |
| 105 | const TEXT_HEAD: Rgba = Rgba::opaque(0x6b, 0x72, 0x80); |
| 106 | const TEXT_DEAD: Rgba = Rgba::opaque(0xb9, 0x1c, 0x1c); // a heading the ledger never placed |
| 107 | const CUR_BG: Rgba = Rgba::opaque(0xdb, 0xea, 0xfe); |
| 108 | const CUR_TEXT: Rgba = Rgba::opaque(0x1d, 0x4e, 0xd8); |
| 109 | const CARET_INK: Rgba = Rgba::opaque(0x9c, 0xa3, 0xaf); |
| 110 | const BTN_ON: Rgba = Rgba::opaque(0x25, 0x63, 0xeb); |
| 111 | const BTN_OFF: Rgba = Rgba::opaque(0xff, 0xff, 0xff); |
| 112 | const BTN_EDGE: Rgba = Rgba::opaque(0xd1, 0xd5, 0xdb); |
| 113 | const 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. |
| 117 | pub 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. |
| 187 | struct 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)] |
| 194 | struct 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)] |
| 201 | struct SideKey { |
| 202 | w: usize, |
| 203 | h: usize, |
| 204 | scale: f64, |
| 205 | scroll: f64, |
| 206 | current: Option<usize>, |
| 207 | version: u64, |
| 208 | } |
| 209 | |
| 210 | type ReaderSurface = softbuffer::Surface<Rc<Window>, Rc<Window>>; |
| 211 | |
| 212 | struct 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 | |
| 235 | impl 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 | |
| 654 | impl 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. |
| 746 | fn 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. |
| 784 | fn 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. |
| 806 | fn 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. |
| 823 | fn 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. |
| 848 | fn 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. |
| 886 | fn 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 | } |