Gunnar

nordisk/facett

.nornir/docview-design.md

# facett-docview — design

> **Class:** `design` · **Function axis:** `f0` (the render pipeline) + `f1` page/font parse ·
> `f2` the lazy `PageSource` seam · `f3` texture cache + viewport scroll · `f4` the Elm/Facet contract.
> Strict: this file states *only the shape we run*. Every *why*, every alternative, lives in
> [`docview-reasoning.md`](docview-reasoning.md). Workflow lives in [`docview-guide.md`](docview-guide.md).

![SVG page bytes → fontdb (system + embedded DejaVu) → usvg → tiny_skia → egui texture](assets/docview_render.svg)

## f0 — the render pipeline (the whole)

`DocView` (`facett-docview/src/lib.rs:372`) shows a manual as a stack of vector SVG pages: a
TOC sidebar, continuous vertical scroll, stepped zoom + fit-width, and a page-jump box —
**pure Rust**, no pdfium, no native libraries. The pipeline is one dependency, `resvg`, which
re-exports `usvg` and `tiny_skia` (`lib.rs:51-52`):

1. **Parse once per page** — `DocPage::from_svg_bytes` (`lib.rs:125`) →
   `usvg::Tree::from_data(bytes, &opts)`; the intrinsic page size in pt is read from
   `tree.size()` and kept on the `DocPage` (`width_pt`/`height_pt`, `lib.rs:110-115`).
2. **Rasterize on demand, at a zoom bucket** — `DocPage::rasterize` (`lib.rs:149`) allocates a
   `tiny_skia::Pixmap` sized to `page_pt × scale`, applies a uniform `Transform::from_scale`,
   calls `resvg::render`, then demultiplies the premultiplied RGBA into an `egui::ColorImage`
   (`lib.rs:162-169`).
3. **Cache by `(page_index, zoom_bucket)`** — `DocView::cache` (`lib.rs:382`), a
   `RefCell<HashMap<(usize, u32), egui::TextureHandle>>`; `DocView::zoom_bucket`
   (`lib.rs:579`) quantizes `zoom × pixels_per_point` to ~2% steps so a page re-rasterizes
   only when its bucket actually changes.
4. **Viewport-bounded** — `DocView::view` (`lib.rs:638`) computes the visible band from
   `ScrollArea::both().show_viewport` (`lib.rs:760-855`); only pages within
   `VIEWPORT_MARGIN_FACTOR` (`lib.rs:67`) of the band rasterize, and `DocView::evict`
   (`lib.rs:593`) drops cache entries and (in lazy mode) parsed pages outside that band.
5. **Clamped** — zoom ∈ `[MIN_ZOOM, MAX_ZOOM]` = `[0.5, 4.0]` (`lib.rs:55-57`), a page's
   pixmap never exceeds `MAX_TEX_DIM` = 4096px in either dimension (`lib.rs:62`, enforced in
   `DocPage::rasterize` at `lib.rs:154-157`).

**Reuse map (what docview `uses`):** `resvg`/`usvg`/`tiny_skia` (the render stack, re-exported
at `lib.rs:51-52`), `rayon` (parallel page parse + rasterize), and facett-core's `Elm` trait,
`impl_facet_via_elm!` bridge, `harness` module, `scroll_engine::{SmoothScroll,
smooth_scroll_area}`, and `clip::{ClipKind, ClipPayload, CopySource}`. It is used *by*
facett-demo's `docview` tab (`facett-demo/src/lib.rs:633`) and by any host embedding a Typst-
or skade-exported manual.

## f1 — page parse + the shared fontdb

`DocPage::from_svg_bytes` (`lib.rs:125`) parses with a `usvg::Options` whose `fontdb` field is
`shared_fontdb()` (`lib.rs:87`) and whose `font_family` is `EMBEDDED_SANS_FAMILY` (`lib.rs:128`,
`:82`). `shared_fontdb` builds the database **once**, behind a `static OnceLock<Arc<...>>`
(`lib.rs:88`), and every subsequent call clones the `Arc` — the expensive
`db.load_system_fonts()` (`lib.rs:92`) runs exactly once per process. After system fonts, it
loads an embedded font, `EMBEDDED_SANS` — `include_bytes!("../assets/DejaVuSans.ttf")`
(`lib.rs:79`) — and sets it as the sans/serif/monospace/cursive/fantasy default family
(`lib.rs:97-101`), so any `<text>` with no usable `font-family` still shapes.

This lets `DocPage::from_svg_bytes` parse two different kinds of SVG through the same path:

| SVG kind | text representation | fontdb involvement |
|---|---|---|
| Typst-exported manual page | every glyph outlined as a vector path | none — outlines render regardless of `fontdb` |
| hand-authored diagram (e.g. skade) | real `<text>` elements with `font-family` | resolved against `shared_fontdb`, falling back to the embedded DejaVu Sans |

## f2 — the lazy `PageSource` seam

`Pages` (`lib.rs:199`) is the page backing, one of two variants:

- `Pages::Eager(Vec<DocPage>)` — every page parsed up front, built by `DocView::new`
  (`lib.rs:403`) / `DocView::from_svgs` (`lib.rs:448`).
- `Pages::Lazy { source, count, cache, size_hint }` — pages fetched + parsed only as they near
  the viewport, built by `DocView::from_source` (`lib.rs:413`).

`PageSource` (`lib.rs:182`) is the only seam for lazy loading: `page_count`, `page_svg(i) ->
Option<Vec<u8>>`, and `page_size_hint()` (default `(595.0, 842.0)`, A4 portrait, `lib.rs:191`).
It is `Send + Sync` so `Pages::ensure_parsed` (`lib.rs:247`) fetches + parses the needed indices
in parallel via `rayon`'s `par_iter` (`lib.rs:255-261`), one page per core. A parsed page's real
size refines `size_hint` (`lib.rs:263-266`) so pre-fetch layout converges to the true page box.
`Pages::evict_parsed` (`lib.rs:289`) drops cached `DocPage`s outside the current viewport band;
`Pages::rasterize_many` (`lib.rs:274`) rasterizes a batch of pages in parallel, again via
`rayon`.

## f3 — texture cache + viewport scroll

`DocView::view` (`lib.rs:638`) drives the scroll/cache machinery each frame:

1. **Geometry pass** — for every page, its logical box (`page_logical_size`, `lib.rs:585`,
   `= page_pt × zoom`) and stacked top offset are precomputed (`lib.rs:735-743`); the visible
   band is `[viewport.min.y − margin, viewport.max.y + margin]` where `margin = viewport.height
   × VIEWPORT_MARGIN_FACTOR` (`lib.rs:767`).
2. **Velocity-biased prefetch** — the band leads in the scroll direction by `scroll_delta ×
   PREFETCH_LEAD` (`lib.rs:72`, `:771-776`), clamped to two screens, so an accelerating fling has
   its upcoming pages parsed + rasterized before they enter view.
3. **Rasterize pass** — pages in the band not already in `DocView::cache` at the current
   `zoom_bucket` are parsed (if lazy) then rasterized in parallel (`lib.rs:813-832`), and each
   result is uploaded via `ui.ctx().load_texture` with `egui::TextureOptions::LINEAR`
   (`lib.rs:825-830`).
4. **Draw pass** — each visible page is painted from the cache with `ui.painter().image`
   (`lib.rs:834-848`).
5. **Smooth scroll** — `DocView::page_scroll_x` / `page_scroll_y` (`lib.rs:394-395`, both
   `Cell<SmoothScroll>`) drive the page stack through `SmoothScroll::follow`
   (`facett-core/src/scroll_engine.rs:46`-area); a pending `Msg::JumpTo` snaps the eased offset
   to the target page's top (`lib.rs:754-758`) so a bookmark lands immediately, while ordinary
   wheel scrolling is eased. The TOC sidebar scrolls through `smooth_scroll_area`
   (`facett-core/src/scroll_engine.rs:163`), called from `draw_sidebar` (`lib.rs:610-625`).
6. **Solid, non-floating scrollbar** — `ui.spacing_mut().scroll = egui::style::ScrollStyle::solid()`
   (`lib.rs:720`) reserves the scrollbar its own lane; `fit_width`'s target column width is the
   panel width minus that reserved lane (`lib.rs:721-727`), so a fitted page's right edge never
   sits under the bar.
7. **Eviction** — `DocView::evict` (`lib.rs:593`) runs every frame with the current visible set
   and zoom bucket, dropping stale cache entries and (lazy mode) far-offscreen parsed pages.

## f4 — the Elm / Facet contract

`DocView` implements `facett_core::Elm` (`lib.rs:927`) with `Model = DocState`, `Msg = Msg`,
`Effect = Effect`:

- **`DocState`** (`lib.rs:343`, `Serialize + Deserialize + PartialEq`) — `zoom`, `show_sidebar`,
  `current_page`, `visible_pages`, `toc`, `scroll_to`. This is the complete observable Model
  (FC-1/FC-3); the parsed/lazy `Pages` engine and the GPU texture cache are **not** in it —
  they live in `RefCell`/`Cell` fields on `DocView` (`lib.rs:376-396`).
- **`Msg`** (`lib.rs:307`) — `ToggleSidebar`, `ZoomIn`, `ZoomOut`, `SetZoom(f32)`,
  `FitWidth(f32)`, `JumpTo(usize)`, `SyncViewport { current_page, visible, consumed_scroll_to }`.
  Every toolbar button, TOC click, jump-box Enter, and the per-frame scroll-derived sync route
  through this one enum (FC-2).
- **`Effect`** (`lib.rs:333`) — an uninhabited enum: `DocView::update` (`lib.rs:543`) never
  asks the host to do I/O (FC-8).
- **`update`** (`lib.rs:543`) is the sole `&mut self` mutation of `DocState`; **`view`**
  (`lib.rs:638`) takes `&self` and returns the frame's `Msg`s (FC-9) — it advances the
  render-engine `RefCell`/`Cell` machinery (cache, lazy parse/evict, smooth-scroll) through
  interior mutability, but never writes `DocState` directly.
- **The bridge** — `facett_core::impl_facet_via_elm!(DocView, custom_state_json, { … })`
  (`lib.rs:956`) writes `impl Facet for DocView`, using form 3 to supply a richer `state_json`
  that folds in non-Model engine signals (`page_count`, `cache_size`, `page_sizes_pt`) alongside
  the Model fields (`lib.rs:961-972`).
- **Caps** — `caps()` returns `FacetCaps::NONE.scalable().themeable().resizable().copyable()`
  (`lib.rs:977`); `scale()`/`set_scale()` (`lib.rs:982-987`) make the deck's uniform zoom
  control drive `DocState::zoom` directly, so `fit_width`/`zoom_in` and the deck's Ctrl-+/-/0
  operate on the same value.
- **Typed copy** — `DocView` implements `CopySource` (`facett-core/src/clip.rs:131`):
  `copy_kinds()` returns `&[ClipKind::Text]` (`lib.rs:908`), and `copy_payload()`
  (`lib.rs:911-923`) serializes the TOC as `"title\tpage"` lines (1-based page numbers) — the
  page rasters carry no extractable text, but the outline does.

`state_json()` shape (`lib.rs:961-972`):

```json
{
  "page_count": 4, "current_page": 0, "zoom": 1.0, "sidebar": true,
  "toc": [{"title": "Welcome", "page": 0}, ...],
  "visible_pages": [0, 1],
  "cache_size": 2,
  "page_sizes_pt": [[420.0, 594.0], ...]
}
```