facett-docview/src/lib.rs
//! **facett-docview** — a documentation viewer. Render a **Typst-authored manual**
//! exported as **one vector SVG per page** inside egui, with a table-of-contents
//! sidebar and continuous vertical scrolling — **pure Rust**, no pdfium / no
//! native libraries.
//!
//! The pipeline is [resvg]/[usvg]/[tiny_skia] (all re-exported from `resvg`):
//!
//! 1. Each page's SVG bytes are parsed **once** at construction
//! ([`usvg::Tree::from_data`]) and the intrinsic page size (in pt) is kept.
//! A Typst-authored page outlines every glyph as a vector path, so it needs
//! **no** font database. But facett-docview also renders SVGs that carry real
//! `<text>` (e.g. skade's diagrams), and usvg silently **drops `<text>` it
//! cannot shape without a font**. So we parse with a [`usvg::Options`] whose
//! `fontdb` is populated (see `shared_fontdb`): system fonts **plus** a small
//! embedded open font (DejaVu Sans, `assets/DejaVuSans.ttf`) set as the
//! default family, so text shapes + renders **even on an airgapped box with
//! zero system fonts** (the nordisk offline-manual ethos). The database is
//! built **once** and shared (an `Arc`) across every page parse — never
//! reloaded per page. Pre-outlined (Typst) pages parse fine through the same
//! options; they simply never touch the fontdb.
//! 2. To show a page at the current zoom we compute `target_px = page_pt * zoom *
//! pixels_per_point`, allocate a [`tiny_skia::Pixmap`], apply a uniform scale
//! [`tiny_skia::Transform`], call [`resvg::render`], convert the RGBA pixmap to
//! an [`egui::ColorImage`], upload it with `ctx.load_texture`, and draw it with
//! `ui.image()`.
//! 3. A **texture cache** keyed by `(page_index, zoom_bucket)` re-rasterizes a page
//! **only when its zoom bucket changes** — never every frame. Only pages in or
//! near the viewport rasterize; far-offscreen textures are dropped to bound
//! memory. Zoom and the per-page texture dimension are both clamped.
//!
//! Source-agnostic: the host hands [`DocView::new`] a `Vec<DocPage>` (each a page's
//! SVG bytes) and a table of contents (`Vec<(title, page_index)>`). It implements
//! the canonical [`facett_core::Facet`] so it drops into a `FacetDeck` and exposes
//! everything it renders via [`Facet::state_json`] (LAW 6).
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use facett_core::scroll_engine::{SmoothScroll, smooth_scroll_area};
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{FacetCaps, Theme, theme};
use znippy_zoomies::gatling_forkjoin::{gatling_for_each, gatling_map_balanced, gatling_map_owned};
use serde::{Deserialize, Serialize};
// Re-export the canonical Facet trait so a host can drive [`DocView`] (call
// `.ui(..)` / `.state_json()`) without taking its own facett-core dependency.
pub use facett_core::Facet;
// resvg re-exports the whole stack, so consumers (and this crate) pin one dep.
pub use resvg::tiny_skia;
pub use resvg::usvg;
/// Zoom is clamped to this range (a stepped zoom in/out + fit-width all clamp here).
pub const MIN_ZOOM: f32 = 0.5;
/// Maximum zoom — see [`MIN_ZOOM`].
pub const MAX_ZOOM: f32 = 4.0;
/// Stepped zoom multiplier for the +/- buttons.
pub const ZOOM_STEP: f32 = 1.25;
/// A rasterized page's pixmap is never allowed to exceed this in either dimension
/// (bounds memory even at MAX_ZOOM on a hi-dpi screen).
pub const MAX_TEX_DIM: u32 = 4096;
/// Vertical gap between stacked pages, in logical points.
pub const PAGE_GAP: f32 = 12.0;
/// Pages within this many viewport-heights of the visible band are rasterized
/// (so scrolling has textures ready); textures further away are dropped.
const VIEWPORT_MARGIN_FACTOR: f32 = 0.75;
/// Velocity-biased prefetch: the band leads in the scroll direction by
/// `scroll_delta × this` (clamped to a couple of screens), so an accelerating
/// fling has the pages it's heading toward parsed + rasterized **before** they
/// enter the viewport — smooth scroll with no pop-in. Bigger = more lead.
const PREFETCH_LEAD: f32 = 6.0;
/// A small open sans-serif ([DejaVu Sans]) embedded into the binary. usvg drops
/// any `<text>` it can't shape without a font, so this is the **airgap floor**:
/// even with an empty system fontdb, every SVG `<text>` still shapes + renders.
///
/// [DejaVu Sans]: https://dejavu-fonts.github.io/
#[cfg(feature = "dejavu")]
const EMBEDDED_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
// **A 131-byte "font" compiles, and this one is not even behind a feature.** MEASURED on
// oden 2026-08-27: this asset was a Git-LFS pointer — 131 B where 759 720 belong — in a
// checkout `git status --porcelain` called clean (the LFS clean filter passes a pointer
// through unchanged, so there is no diff to report). `usvg` drops any `<text>` it cannot
// shape, so the airgap floor this constant IS would have silently become no floor at all:
// every `<text>` in every SVG page gone, with no error anywhere. `const`, so the build
// fails instead. The predicate is `facett_core`'s, shared with the Inter site in
// `facett-core/src/look/mod.rs` (LAW 5).
#[cfg(feature = "dejavu")]
const _: () = assert!(
!facett_core::asset::is_a_git_lfs_pointer(EMBEDDED_SANS) && EMBEDDED_SANS.len() > 4096,
"facett-docview/assets/DejaVuSans.ttf is a Git-LFS pointer or a stub, not a font. Run \
`git lfs checkout` in this repo — `git status` will not tell you, it reads clean \
either way."
);
/// The family name [`EMBEDDED_SANS`] registers under in `fontdb` — used as the
/// default family so unspecified / unresolved `font-family`s fall back to it.
#[cfg(feature = "dejavu")]
const EMBEDDED_SANS_FAMILY: &str = "DejaVu Sans";
/// Without `dejavu` (a wasm bundle that wraps docview): egui's own default
/// proportional face, Ubuntu-Light, taken from `egui::FontDefinitions::default()` at
/// run time — the SAME static bytes egui links for its default fonts. Naming
/// `epaint_default_fonts::UBUNTU_LIGHT` here instead would NOT share them: it is a
/// `const &[u8]`, and MEASURED in the facet-wrapped bundle (2026-09-29) that put a
/// second 362 KB copy of the face beside egui's.
#[cfg(not(feature = "dejavu"))]
fn embedded_sans() -> Vec<u8> {
egui::FontDefinitions::default()
.font_data
.get("Ubuntu-Light")
.map(|d| d.font.to_vec())
.expect("egui's default fonts carry Ubuntu-Light (egui's `default_fonts` feature is on)")
}
#[cfg(feature = "dejavu")]
fn embedded_sans() -> Vec<u8> {
EMBEDDED_SANS.to_vec()
}
#[cfg(not(feature = "dejavu"))]
const EMBEDDED_SANS_FAMILY: &str = "Ubuntu";
/// The process-wide font database, built **once** and shared (`Arc`) by every
/// page parse. Loading system fonts is expensive, so we never do it per page;
/// the embedded font is loaded last so it's always present as a fallback.
fn shared_fontdb() -> Arc<usvg::fontdb::Database> {
static DB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
DB.get_or_init(|| {
let mut db = usvg::fontdb::Database::new();
// System fonts first (so a host's real fonts are used when present)…
db.load_system_fonts();
// …then the embedded font as a guaranteed fallback (airgap / determinism).
db.load_font_data(embedded_sans());
// Point the generic families at the embedded font so an SVG with no usable
// `font-family` (or one naming a missing font) still shapes against it.
db.set_sans_serif_family(EMBEDDED_SANS_FAMILY);
db.set_serif_family(EMBEDDED_SANS_FAMILY);
db.set_monospace_family(EMBEDDED_SANS_FAMILY);
db.set_cursive_family(EMBEDDED_SANS_FAMILY);
db.set_fantasy_family(EMBEDDED_SANS_FAMILY);
Arc::new(db)
})
.clone()
}
/// The full-text search index: `(0-based page index, lowercased page text)` for
/// every page that carries a non-empty text layer. Built once, shared as an `Arc`.
type SearchIndex = Vec<(usize, String)>;
/// One page of the manual: its source SVG, parsed once into a [`usvg::Tree`], plus
/// the intrinsic page size in pt (from the SVG's own dimensions).
pub struct DocPage {
tree: usvg::Tree,
/// Intrinsic page width in pt (SVG user units).
width_pt: f32,
/// Intrinsic page height in pt.
height_pt: f32,
/// The page's **searchable text layer**: every `<text>` run usvg kept, in
/// document order, space-joined. This is real text ONLY when the SVG carries
/// real `<text>` (skade's diagrams, the built-in fallback pages). A
/// Typst-authored page **outlines every glyph to a `<path>`** — it has no
/// `<text>` nodes — so this is empty there, and that page is honestly
/// unsearchable (see [`DocView`]'s search notes). Extracted once at parse.
text: String,
}
impl DocPage {
/// Parse one page's SVG bytes into a [`DocPage`]. The parse [`usvg::Options`]
/// carry a populated `fontdb` ([`shared_fontdb`]: system fonts + an embedded
/// fallback) and a default family, so SVGs with real `<text>` (skade's
/// diagrams) shape + render — usvg would otherwise **drop** unshapeable text.
/// Pre-outlined (Typst) pages parse identically; they just never use the
/// fontdb. The shared `Arc<fontdb::Database>` is cloned (cheap), never rebuilt.
/// Returns the usvg parse error on malformed SVG (the host can skip a bad page).
pub fn from_svg_bytes(bytes: &[u8]) -> Result<Self, usvg::Error> {
let opts = usvg::Options {
fontdb: shared_fontdb(),
font_family: EMBEDDED_SANS_FAMILY.to_string(),
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(bytes, &opts)?;
let size = tree.size();
let text = extract_text(tree.root());
Ok(Self { tree, width_pt: size.width(), height_pt: size.height(), text })
}
/// Intrinsic page width in pt.
pub fn width_pt(&self) -> f32 {
self.width_pt
}
/// Intrinsic page height in pt.
pub fn height_pt(&self) -> f32 {
self.height_pt
}
/// This page's extracted searchable text (see [`DocPage::text`]). Empty for a
/// glyph-outlined (Typst) page that carries no real `<text>`.
pub fn text(&self) -> &str {
&self.text
}
/// Rasterize this page at `scale` (px-per-pt) into an [`egui::ColorImage`].
/// `scale` already folds zoom × pixels_per_point; the result is clamped so
/// neither dimension exceeds [`MAX_TEX_DIM`]. Returns `None` if the page has a
/// degenerate (zero) size.
pub fn rasterize(&self, scale: f32) -> Option<egui::ColorImage> {
if self.width_pt <= 0.0 || self.height_pt <= 0.0 || scale <= 0.0 {
return None;
}
// Clamp the effective scale so the biggest dimension fits MAX_TEX_DIM.
let max_pt = self.width_pt.max(self.height_pt);
let scale = scale.min(MAX_TEX_DIM as f32 / max_pt);
let w = (self.width_pt * scale).round().max(1.0) as u32;
let h = (self.height_pt * scale).round().max(1.0) as u32;
let mut pixmap = tiny_skia::Pixmap::new(w, h)?;
let transform = tiny_skia::Transform::from_scale(scale, scale);
resvg::render(&self.tree, transform, &mut pixmap.as_mut());
// tiny_skia stores premultiplied RGBA; egui wants straight RGBA8.
let pixels = pixmap.pixels();
let mut rgba = Vec::with_capacity(pixels.len() * 4);
for p in pixels {
// `PremultipliedColorU8::demultiply` → straight `ColorU8`.
let c = p.demultiply();
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
Some(egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba))
}
}
/// Walk a parsed SVG tree and collect every `<text>` run usvg retained, in
/// document order, space-joined — the page's searchable text layer. Returns an
/// empty string for a glyph-outlined page (no `<text>` nodes), which is exactly
/// how a Typst-authored page presents: honest, not faked.
fn extract_text(group: &usvg::Group) -> String {
let mut out = String::new();
collect_text(group, &mut out);
out.trim().to_string()
}
/// Recursive worker for [`extract_text`]: append each [`usvg::Node::Text`]'s
/// chunk text and descend into groups.
fn collect_text(group: &usvg::Group, out: &mut String) {
for node in group.children() {
match node {
usvg::Node::Text(t) => {
for chunk in t.chunks() {
let s = chunk.text();
if !s.is_empty() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(s);
}
}
}
usvg::Node::Group(g) => collect_text(g, out),
_ => {}
}
}
}
/// A source of page SVG bytes, fetched **on demand** by 0-based index. The host
/// implements this (e.g. over a compressed/archived manual) and the viewer pulls
/// only the pages the viewport needs — so a huge manual never parses or holds all
/// pages at once. Must be `Send + Sync`: the viewer fetches + parses the needed
/// pages in parallel (the shared fork-join gatling, 1 page per core — no thread pool).
///
/// This is the ONLY seam the viewer exposes for lazy loading — it knows nothing
/// about where the bytes come from (file, archive, network); it just asks for
/// page `i` when `i` scrolls near the viewport.
pub trait PageSource: Send + Sync {
/// Total number of pages.
fn page_count(&self) -> usize;
/// Raw SVG bytes for page `i`, or `None` if unavailable / out of range.
fn page_svg(&self, i: usize) -> Option<Vec<u8>>;
/// Optional size hint (width_pt, height_pt) used to lay out not-yet-parsed
/// pages so the scrollbar is stable before they're fetched. Defaults to A4
/// portrait (595×842 pt) — exact for a uniform typst manual; refined from the
/// first parsed page regardless.
fn page_size_hint(&self) -> (f32, f32) {
(595.0, 842.0)
}
/// Optional **searchable text** for page `i` — a text sidecar the source can
/// carry alongside the SVG bytes so the viewer can index + search a book whose
/// pages are glyph-outlined (Typst → `<path>`, no real `<text>`). Default
/// `None`: the source has no text layer, so lazy search is unavailable for it
/// (honest limitation — the viewer never fakes a hit). A packer that wants
/// searchable manuals should emit one string of plain page text per page here
/// (e.g. from the pre-render markdown), keeping page rasterization O(1).
fn page_text(&self, _i: usize) -> Option<String> {
None
}
}
/// The viewer's page backing: either every page parsed up front (`Eager`, the
/// `new`/`from_svgs` path) or fetched+parsed on demand from a [`PageSource`]
/// (`Lazy`) and evicted to the viewport band so memory stays bounded.
enum Pages {
Eager(Vec<DocPage>),
Lazy {
source: Box<dyn PageSource>,
count: usize,
/// Parsed pages, lazily filled; bounded by [`Pages::evict_parsed`].
cache: HashMap<usize, DocPage>,
/// Size (pt) assumed for not-yet-parsed pages — refined from the first
/// parse so layout converges to the true page box.
size_hint: (f32, f32),
},
}
impl Pages {
fn len(&self) -> usize {
match self {
Pages::Eager(v) => v.len(),
Pages::Lazy { count, .. } => *count,
}
}
fn is_empty(&self) -> bool {
self.len() == 0
}
/// Best-known (width_pt, height_pt) for page `i`: the parsed size when
/// available, else the lazy size hint (so layout is stable pre-parse).
fn size_pt(&self, i: usize) -> (f32, f32) {
match self {
Pages::Eager(v) => v.get(i).map(|p| (p.width_pt, p.height_pt)).unwrap_or((0.0, 0.0)),
Pages::Lazy { cache, size_hint, .. } => {
cache.get(&i).map(|p| (p.width_pt, p.height_pt)).unwrap_or(*size_hint)
}
}
}
/// The searchable text for page `i`, if any. Eager: the page's extracted
/// `<text>` layer. Lazy: the source's text sidecar ([`PageSource::page_text`])
/// — NOT the volatile parse cache, so the search index is complete + stable
/// (a page becomes searchable because the source carries its text, never
/// because it happened to scroll into view).
fn page_text(&self, i: usize) -> Option<String> {
match self {
Pages::Eager(v) => v.get(i).map(|p| p.text.clone()),
Pages::Lazy { source, .. } => source.page_text(i),
}
}
/// Width of the first page (for fit-width), if known.
fn first_width_pt(&self) -> Option<f32> {
match self {
Pages::Eager(v) => v.first().map(|p| p.width_pt).filter(|w| *w > 0.0),
Pages::Lazy { cache, size_hint, .. } => {
Some(cache.get(&0).map(|p| p.width_pt).unwrap_or(size_hint.0)).filter(|w| *w > 0.0)
}
}
}
/// Ensure every page in `need` is parsed (lazy fetches + parses the missing
/// ones in parallel, then refines the size hint). No-op for eager pages.
fn ensure_parsed(&mut self, need: &[usize]) {
if let Pages::Lazy { source, cache, size_hint, .. } = self {
let to_parse: Vec<usize> =
need.iter().copied().filter(|i| !cache.contains_key(i)).collect();
if to_parse.is_empty() {
return;
}
let src = &**source;
// Fetch + parse each missing page on its own core via the fork-join gatling
// (no thread pool): workers self-dispatch off one atomic counter and each touches
// a DISJOINT page index — no shared state. Fetch cost isn't known upfront
// (it's the I/O we're issuing), so this is the plain index fan-out rather
// than the balanced one. `flatten` drops pages that failed to fetch/parse
// while preserving source order (gatling returns results in index order).
let parsed: Vec<(usize, DocPage)> = gatling_for_each(to_parse.len(), 0, |k| {
let i = to_parse[k];
let bytes = src.page_svg(i)?;
DocPage::from_svg_bytes(&bytes).ok().map(|p| (i, p))
})
.into_iter()
.flatten()
.collect();
for (i, p) in parsed {
// Refine the layout hint from a real page (uniform manuals → exact).
if p.width_pt > 0.0 && p.height_pt > 0.0 {
*size_hint = (p.width_pt, p.height_pt);
}
cache.insert(i, p);
}
}
}
/// Rasterize each page in `need` (that is parsed + available) at `scale`, in
/// parallel — 1 page per core. Returns `(page_index, image)` pairs to upload.
fn rasterize_many(&self, need: &[usize], scale: f32) -> Vec<(usize, egui::ColorImage)> {
// Look up a parsed page regardless of backing (eager Vec / lazy cache).
let page = |i: usize| -> Option<&DocPage> {
match self {
Pages::Eager(v) => v.get(i),
Pages::Lazy { cache, .. } => cache.get(&i),
}
};
// Rasterize the band one page per core on the balanced fork-join gatling (no
// thread pool): page raster cost is skewed (a dense diagram page costs far more than
// a sparse text page), so the balanced schedule claims the fat pages first and
// no core idles waiting on a slow sibling. Weight = rasterized pixel area
// (w·h·scale²). Each worker renders a DISJOINT page into its own image (no
// shared state, zero-copy index hand-off, no alloc in the fan-out). `flatten`
// drops pages with no parsed tree / no image, preserving `need` order.
gatling_map_balanced(
need,
0, // one worker per core
1, // one page per steal — pages are coarse, cache-heavy units
|&i| {
page(i)
.map(|p| (p.width_pt as f64 * p.height_pt as f64 * (scale * scale) as f64) as u64)
.unwrap_or(0)
},
|_, &i| page(i).and_then(|p| p.rasterize(scale)).map(|img| (i, img)),
)
.into_iter()
.flatten()
.collect()
}
/// Drop parsed pages outside `keep` — bounds memory in lazy mode (eager keeps
/// everything, since the host chose to hold it all).
fn evict_parsed(&mut self, keep: &[usize]) {
if let Pages::Lazy { cache, .. } = self {
cache.retain(|i, _| keep.contains(i));
}
}
/// Per-page best-known sizes (for `state_json`).
fn sizes_pt(&self) -> Vec<[f32; 2]> {
(0..self.len()).map(|i| { let (w, h) = self.size_pt(i); [w, h] }).collect()
}
}
/// A robot-/CLI-addressable control message — every input the viewer accepts
/// (FC-2). The canvas gestures (toolbar buttons, TOC clicks, the jump box, and the
/// per-frame scroll-derived viewport sync) route through the **same** [`Msg`]s the
/// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge feeds
/// [`DocView::update`], so a headless driver reproduces the live transition.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Toggle the table-of-contents sidebar.
ToggleSidebar,
/// Stepped zoom in (× [`ZOOM_STEP`], clamped).
ZoomIn,
/// Stepped zoom out (÷ [`ZOOM_STEP`], clamped).
ZoomOut,
/// Set the zoom factor directly (clamped to `[MIN_ZOOM, MAX_ZOOM]`).
SetZoom(f32),
/// Fit the first page's width into this many logical px of content column.
FitWidth(f32),
/// Fit a whole page into `(content_width, viewport_height)` logical px — the
/// smaller of the width- and height-fit zooms, so an entire page is visible.
FitPage(f32, f32),
/// Toggle between continuous (scroll all pages) and paged (one page) reading.
ToggleMode,
/// Set the reading mode directly: `true` = continuous, `false` = paged.
SetContinuous(bool),
/// Set the live search query and recompute the hit set (case-insensitive
/// substring over the page text layer). Does not scroll; use
/// [`Msg::NextHit`]/[`Msg::PrevHit`] to jump.
SetQuery(String),
/// Clear the search query + hits.
ClearSearch,
/// Focus the next search hit (wraps) and scroll to its page.
NextHit,
/// Focus the previous search hit (wraps) and scroll to its page.
PrevHit,
/// Request a scroll to this 0-based page (clamped) — the TOC click / jump box.
JumpTo(usize),
/// The per-frame result of the scroll layout: the centremost page + the in/near
/// viewport band, and whether this frame consumed a pending [`JumpTo`](Msg::JumpTo).
/// Emitted by the pure [`view`](DocView::view) so the derived scroll state
/// mutates the Model **only** through [`update`](DocView::update) (FC-2/FC-9).
SyncViewport { current_page: usize, visible: Vec<usize>, consumed_scroll_to: bool },
}
/// Side work as data (FC-8). The viewer does no host I/O through the Model — every
/// [`Msg`] mutates only the in-memory [`DocState`] (the lazy page fetch + texture
/// rasterization are render-engine internals, not Model effects) — so this is
/// uninhabited on purpose: the type-checked statement that [`DocView::update`]
/// never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// **The complete observable, serializable Model (FC-1 / FC-3)** — the mutable
/// control state of a [`DocView`], round-trippable through serde. The non-Model
/// halves (the parsed/lazy [`Pages`] engine and the GPU texture cache) are NOT
/// serializable (they hold [`usvg::Tree`]s / `TextureHandle`s), so they live
/// outside this struct; the richer `state_json` folds their derived signals
/// (`page_count`, `cache_size`, `page_sizes_pt`) back in. The **only** thing that
/// mutates this is [`DocView::update`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DocState {
/// Current zoom factor (1.0 = native pt → logical px). Clamped to
/// `[MIN_ZOOM, MAX_ZOOM]`.
pub zoom: f32,
/// Whether the sidebar (TOC) is shown.
pub show_sidebar: bool,
/// The page the scroll position is currently centred on (0-based) — derived
/// from the scroll offset each frame via [`Msg::SyncViewport`].
pub current_page: usize,
/// The page indices rasterized in the most recent frame (in/near viewport).
pub visible_pages: Vec<usize>,
/// Table of contents: `(entry title, 0-based page index)`, caller-provided.
pub toc: Vec<(String, usize)>,
/// A pending scroll-to-page request (set by [`Msg::JumpTo`]), consumed on the
/// next render via [`Msg::SyncViewport`]'s `consumed_scroll_to`.
pub scroll_to: Option<usize>,
/// Reading mode: `true` = continuous (scroll every page top-to-bottom),
/// `false` = paged (one page centred, next/prev). Continuous by default.
#[serde(default = "default_true")]
pub continuous: bool,
/// Live search query — case-insensitive substring over the page text layer.
#[serde(default)]
pub query: String,
/// The 0-based page indices whose text layer contains [`query`](DocState::query),
/// ascending. Empty when the query is empty, nothing matched, or the book has
/// no searchable text layer.
#[serde(default)]
pub search_hits: Vec<usize>,
/// Index into [`search_hits`](DocState::search_hits) of the focused hit
/// (next/prev cycle through). `0` when there are no hits.
#[serde(default)]
pub active_hit: usize,
/// Whether a hit has been focused yet since the query changed. The FIRST
/// next/prev lands on the current [`active_hit`](DocState::active_hit) (hit 0);
/// subsequent ones step — so a fresh query's first "next" goes to the first
/// match, not the second (browser find semantics). Not exposed in `state_json`.
#[serde(default)]
pub hit_focused: bool,
}
/// serde default for [`DocState::continuous`] (an absent field loads as `true`,
/// the continuous reading mode).
fn default_true() -> bool {
true
}
/// A documentation viewer: a stack of vector SVG pages with a TOC sidebar,
/// continuous scrolling, stepped zoom + fit-width, and a page-jump box.
///
/// The observable, serializable control state lives in [`state`](DocView::state)
/// (a [`DocState`]); everything else is **render-engine machinery** kept in
/// interior-mutable cells so the FC-9 pure [`view`](DocView::view) (which takes
/// `&self`) can rasterize, cache, lazily parse, and animate the smooth-scroll
/// without a `&mut self`. That machinery (texture cache, lazy page parse/eviction,
/// smooth-scroll advance) is the honest **residual impurity** of this component —
/// none of it is observable Model state, but `view` does mutate it through the
/// cells because it needs the live `egui::Ui`/GPU it can't get in `update`.
pub struct DocView {
pub title: String,
/// The observable, serializable Model (FC-3).
state: DocState,
/// The page backing (parsed-or-lazy) — not serializable; interior-mutable so a
/// lazy render can fetch+parse+evict pages under the `&self` view.
pages: RefCell<Pages>,
/// Texture cache keyed by `(page_index, zoom_bucket)` — a page is re-rasterized
/// ONLY when its zoom bucket changes, never per frame. Interior-mutable: the
/// pure view uploads into it.
cache: RefCell<HashMap<(usize, u32), egui::TextureHandle>>,
/// Buffer backing the page-jump text box (1-based, as the user types). Not
/// observable (absent from `state_json`); a render-only scratch buffer.
jump_buf: RefCell<String>,
/// Lazily-built, then cached, search index: `(page_index, lowercased text)`
/// for every page that carries a non-empty text layer. Built once (text never
/// changes), shared as an `Arc` so `recompute_hits` can iterate it without a
/// borrow tangle. `None` until first search / first `search_available` query.
search_index: RefCell<Option<Arc<SearchIndex>>>,
/// Previous frame's scroll offset (content-y). Drives velocity-biased
/// prefetch: the lazy band leads in the scroll direction so a fling has its
/// upcoming pages ready (see [`PREFETCH_LEAD`]).
last_scroll_y: Cell<f32>,
/// Smooth scroll (both axes) for the page stack, + the TOC sidebar (SCRL-3):
/// eased, injected-clock (FC-7). A programmatic page jump retargets
/// `page_scroll_y` so it *glides* to the page. `SmoothScroll` is `Copy`, so
/// these are plain `Cell`s the view reads/advances/writes back.
page_scroll_x: Cell<SmoothScroll>,
page_scroll_y: Cell<SmoothScroll>,
toc_scroll: Cell<SmoothScroll>,
}
impl DocView {
/// Build a viewer over already-parsed [`DocPage`]s with a caller-supplied table
/// of contents (`(title, page_index)`). Out-of-range TOC indices are kept as-is
/// but clamped when a jump is actually performed.
pub fn new(title: impl Into<String>, pages: Vec<DocPage>, toc: Vec<(String, usize)>) -> Self {
Self::with_pages(title, Pages::Eager(pages), toc)
}
/// Build a **lazy** viewer over a [`PageSource`]: pages are fetched + parsed
/// only as they scroll near the viewport, and dropped when they leave it — so
/// a huge manual (hundreds of MB of SVG) opens instantly and never holds more
/// than the visible band in memory. The host supplies bytes by index (e.g.
/// from a compressed archive with O(1) per-entry reads); the viewer never sees
/// the storage. The TOC is taken verbatim.
pub fn from_source(
title: impl Into<String>,
source: Box<dyn PageSource>,
toc: Vec<(String, usize)>,
) -> Self {
let count = source.page_count();
let size_hint = source.page_size_hint();
Self::with_pages(title, Pages::Lazy { source, count, cache: HashMap::new(), size_hint }, toc)
}
fn with_pages(title: impl Into<String>, pages: Pages, toc: Vec<(String, usize)>) -> Self {
Self {
title: title.into(),
state: DocState {
zoom: 1.0,
show_sidebar: true,
current_page: 0,
visible_pages: Vec::new(),
toc,
scroll_to: None,
continuous: true,
query: String::new(),
search_hits: Vec::new(),
active_hit: 0,
hit_focused: false,
},
pages: RefCell::new(pages),
cache: RefCell::new(HashMap::new()),
jump_buf: RefCell::new("1".to_string()),
search_index: RefCell::new(None),
last_scroll_y: Cell::new(0.0),
page_scroll_x: Cell::new(SmoothScroll::default()),
page_scroll_y: Cell::new(SmoothScroll::default()),
toc_scroll: Cell::new(SmoothScroll::default()),
}
}
/// Build a viewer by parsing each page's raw SVG bytes. Pages that fail to
/// parse are skipped (returned in the `Err`-count via [`DocView::page_count`]
/// vs. the input length); the TOC is taken verbatim. This is the host-friendly
/// constructor for `include_bytes!`-bundled manuals.
pub fn from_svgs<I, B>(title: impl Into<String>, svgs: I, toc: Vec<(String, usize)>) -> Self
where
I: IntoIterator<Item = B>,
B: AsRef<[u8]> + Send,
{
// Parse every page on its own core (1 page per core) — usvg parse is the
// expensive construction step for a fat manual (hundreds of pages). The
// owned-item fork-join gatling (no thread pool) MOVES each page's bytes into exactly
// one worker; workers self-dispatch off one atomic counter and results come
// back in index order, so `flatten` preserves source order (a dropped,
// unparseable page shifts nothing the TOC points at — the host's TOC is
// authored against the same set).
let svgs: Vec<B> = svgs.into_iter().collect();
let pages: Vec<DocPage> =
gatling_map_owned(svgs, |b| DocPage::from_svg_bytes(b.as_ref()).ok())
.into_iter()
.flatten()
.collect();
Self::new(title, pages, toc)
}
/// **FC-3** — read the complete observable Model at any frame boundary.
pub fn state(&self) -> &DocState {
&self.state
}
/// Number of pages (parsed-or-lazy).
pub fn page_count(&self) -> usize {
self.pages.borrow().len()
}
/// The current zoom factor.
pub fn zoom(&self) -> f32 {
self.state.zoom
}
/// The 0-based page the scroll position is centred on.
pub fn current_page(&self) -> usize {
self.state.current_page
}
/// The table of contents (`(title, page_index)`).
pub fn toc(&self) -> &[(String, usize)] {
&self.state.toc
}
/// Set zoom, clamped to `[MIN_ZOOM, MAX_ZOOM]`. Returns the applied value.
/// Routes through [`update`](DocView::update) (the single mutation path).
pub fn set_zoom(&mut self, z: f32) -> f32 {
self.update(Msg::SetZoom(z));
self.state.zoom
}
/// Stepped zoom in (× [`ZOOM_STEP`], clamped).
pub fn zoom_in(&mut self) -> f32 {
self.update(Msg::ZoomIn);
self.state.zoom
}
/// Stepped zoom out (÷ [`ZOOM_STEP`], clamped).
pub fn zoom_out(&mut self) -> f32 {
self.update(Msg::ZoomOut);
self.state.zoom
}
/// Fit the first page's width into `panel_width` logical px (`zoom =
/// panel_width / page_width_pt`), clamped. No-op (returns current zoom) when
/// there are no pages.
pub fn fit_width(&mut self, panel_width: f32) -> f32 {
self.update(Msg::FitWidth(panel_width));
self.state.zoom
}
/// Request a scroll to `page` (0-based, clamped) on the next render — the
/// programmatic equivalent of clicking its TOC entry.
pub fn jump_to(&mut self, page: usize) {
self.update(Msg::JumpTo(page));
}
/// Toggle the TOC sidebar.
pub fn toggle_sidebar(&mut self) {
self.update(Msg::ToggleSidebar);
}
/// Fit a whole page into `avail` logical px (min of width- and height-fit).
pub fn fit_page(&mut self, avail: egui::Vec2) -> f32 {
self.update(Msg::FitPage(avail.x, avail.y));
self.state.zoom
}
/// Toggle between continuous and paged reading mode.
pub fn toggle_mode(&mut self) {
self.update(Msg::ToggleMode);
}
/// Set the reading mode (`true` = continuous, `false` = paged).
pub fn set_continuous(&mut self, continuous: bool) {
self.update(Msg::SetContinuous(continuous));
}
/// Whether the viewer is in continuous (vs paged) reading mode.
pub fn is_continuous(&self) -> bool {
self.state.continuous
}
/// Set the search query and recompute the hit set (no scroll).
pub fn set_query(&mut self, query: impl Into<String>) {
self.update(Msg::SetQuery(query.into()));
}
/// Clear the search query + hits.
pub fn clear_search(&mut self) {
self.update(Msg::ClearSearch);
}
/// Focus + scroll to the next search hit (wraps).
pub fn next_hit(&mut self) {
self.update(Msg::NextHit);
}
/// Focus + scroll to the previous search hit (wraps).
pub fn prev_hit(&mut self) {
self.update(Msg::PrevHit);
}
/// The current search query.
pub fn query(&self) -> &str {
&self.state.query
}
/// The 0-based pages whose text matched the query (ascending).
pub fn search_hits(&self) -> &[usize] {
&self.state.search_hits
}
/// Whether the book has any searchable text at all (a page with a non-empty
/// text layer). `false` for a fully glyph-outlined book with no text sidecar —
/// the honest signal a UI shows as "no searchable text in this book".
pub fn search_available(&self) -> bool {
!self.search_index().is_empty()
}
/// Build-once, then reuse the `(page, lowercased text)` search index (see the
/// [`search_index`](DocView::search_index) field). Cheap `Arc` clone per call.
fn search_index(&self) -> Arc<SearchIndex> {
if let Some(idx) = self.search_index.borrow().as_ref() {
return idx.clone();
}
let built = Arc::new(self.build_search_index());
*self.search_index.borrow_mut() = Some(built.clone());
built
}
/// Collect `(page, lowercased text)` for every page carrying a non-empty text
/// layer. Empty when the book is fully glyph-outlined with no text sidecar.
fn build_search_index(&self) -> SearchIndex {
let pages = self.pages.borrow();
(0..pages.len())
.filter_map(|i| {
let t = pages.page_text(i)?;
let t = t.trim();
(!t.is_empty()).then(|| (i, t.to_lowercase()))
})
.collect()
}
/// Recompute [`search_hits`](DocState::search_hits) from the current query,
/// resetting the focused hit to the first. No scroll (that is next/prev's job).
fn recompute_hits(&mut self) {
self.state.active_hit = 0;
self.state.hit_focused = false;
let q = self.state.query.trim().to_lowercase();
if q.is_empty() {
self.state.search_hits.clear();
return;
}
let hits: Vec<usize> = {
let idx = self.search_index();
idx.iter().filter(|(_, t)| t.contains(&q)).map(|(i, _)| *i).collect()
};
self.state.search_hits = hits;
}
/// Step the focused search hit by `delta` (wrapping) and queue a scroll to it.
fn step_hit(&mut self, delta: isize) {
let n = self.state.search_hits.len();
if n == 0 {
return;
}
// The first next/prev after a query change focuses the current hit (0);
// only subsequent ones actually step (browser find semantics).
if self.state.hit_focused {
let cur = self.state.active_hit.min(n - 1) as isize;
self.state.active_hit = ((cur + delta).rem_euclid(n as isize)) as usize;
} else {
self.state.hit_focused = true;
}
let page = self.state.search_hits[self.state.active_hit.min(n - 1)];
self.state.scroll_to = Some(page);
self.state.current_page = page;
}
/// The page the currently-focused search hit lives on, if any.
fn active_hit_page(&self) -> Option<usize> {
self.state.search_hits.get(self.state.active_hit).copied()
}
/// The page indices rasterized in the last frame (in/near the viewport).
pub fn visible_pages(&self) -> &[usize] {
&self.state.visible_pages
}
/// Number of textures currently held in the rasterization cache.
pub fn cache_len(&self) -> usize {
self.cache.borrow().len()
}
/// **FC-2 / FC-8** — the single mutation path for the observable [`DocState`].
/// Apply one [`Msg`]; returns the (always empty) [`Effect`]s the host should
/// run. The toolbar/TOC/jump-box gestures and the per-frame scroll sync all
/// route their [`Msg`]s here, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::ToggleSidebar => self.state.show_sidebar = !self.state.show_sidebar,
Msg::ZoomIn => self.state.zoom = (self.state.zoom * ZOOM_STEP).clamp(MIN_ZOOM, MAX_ZOOM),
Msg::ZoomOut => self.state.zoom = (self.state.zoom / ZOOM_STEP).clamp(MIN_ZOOM, MAX_ZOOM),
Msg::SetZoom(z) => self.state.zoom = z.clamp(MIN_ZOOM, MAX_ZOOM),
Msg::FitWidth(panel_width) => {
if let Some(w) = self.pages.borrow().first_width_pt()
&& panel_width > 0.0
{
self.state.zoom = (panel_width / w).clamp(MIN_ZOOM, MAX_ZOOM);
}
}
Msg::FitPage(w_px, h_px) => {
let (pw, ph) = self.pages.borrow().size_pt(self.state.current_page);
if pw > 0.0 && ph > 0.0 && w_px > 0.0 && h_px > 0.0 {
let z = (w_px / pw).min(h_px / ph);
self.state.zoom = z.clamp(MIN_ZOOM, MAX_ZOOM);
}
}
Msg::ToggleMode => self.state.continuous = !self.state.continuous,
Msg::SetContinuous(c) => self.state.continuous = c,
Msg::SetQuery(q) => {
self.state.query = q;
self.recompute_hits();
}
Msg::ClearSearch => {
self.state.query.clear();
self.state.search_hits.clear();
self.state.active_hit = 0;
self.state.hit_focused = false;
}
Msg::NextHit => self.step_hit(1),
Msg::PrevHit => self.step_hit(-1),
Msg::JumpTo(page) => {
let len = self.pages.borrow().len();
if len > 0 {
let page = page.min(len - 1);
self.state.scroll_to = Some(page);
self.state.current_page = page;
}
}
Msg::SyncViewport { current_page, visible, consumed_scroll_to } => {
self.state.current_page = current_page;
self.state.visible_pages = visible;
if consumed_scroll_to {
self.state.scroll_to = None;
}
}
}
Vec::new()
}
/// The discrete zoom bucket for the current zoom × `pixels_per_point`. The
/// cache key's second component: a page re-rasterizes only when this changes.
/// Bucketing to ~2% steps keeps tiny zoom jitter from thrashing the cache while
/// still re-sharpening on a real zoom.
fn zoom_bucket(&self, pixels_per_point: f32) -> u32 {
(self.state.zoom * pixels_per_point * 50.0).round() as u32
}
/// Logical (zoom-applied, pre-dpi) size of a page in points→logical-px: the
/// box the page occupies in the scroll layout.
fn page_logical_size(&self, page: usize) -> egui::Vec2 {
let (w, h) = self.pages.borrow().size_pt(page);
egui::vec2(w * self.state.zoom, h * self.state.zoom)
}
/// Drop cached textures whose page is outside `keep` (the in/near-viewport set)
/// or whose zoom bucket is stale — bounds memory during scroll/zoom. Takes
/// `&self` (render-engine machinery via interior mutability, not Model state).
fn evict(&self, keep: &[usize], bucket: u32) {
self.cache.borrow_mut().retain(|&(page, b), _| b == bucket && keep.contains(&page));
// Lazy mode: also drop parsed page trees outside the band, so a huge
// manual never holds more than the visible window in memory (eager: no-op).
self.pages.borrow_mut().evict_parsed(keep);
}
/// Draw the sidebar TOC; clicking an entry pushes a [`Msg::JumpTo`]. Pure w.r.t.
/// the Model — it only advances the (render-only) TOC smooth-scroll cell.
fn draw_sidebar(&self, ui: &mut egui::Ui, th: &Theme, msgs: &mut Vec<Msg>) {
ui.label(egui::RichText::new("Contents").color(th.text).strong());
ui.separator();
let entries: Vec<(String, usize)> = self.state.toc.clone();
// Smooth-scroll the TOC (SCRL-3) from this frame's `stable_dt` (FC-7);
// `SmoothScroll` is `Copy`, so work on a copy and write it back.
let dt = ui.input(|i| i.stable_dt);
let mut toc = self.toc_scroll.get();
smooth_scroll_area(
ui,
egui::ScrollArea::vertical().id_salt("docview-toc"),
dt,
&mut toc,
None,
|ui| {
for (label, page) in entries {
let is_current = page == self.state.current_page;
// Mark TOC entries whose page carries a search hit with a dot,
// so the sidebar doubles as a hit map during a search.
let is_hit = self.state.search_hits.contains(&page);
let shown = if is_hit { format!("• {label}") } else { label.clone() };
let color = if is_current {
th.accent
} else if is_hit {
th.text
} else {
th.text_dim
};
let text = egui::RichText::new(shown).color(color);
if ui.selectable_label(is_current, text).clicked() {
msgs.push(Msg::JumpTo(page));
}
}
},
);
self.toc_scroll.set(toc);
}
/// Ensure every page in `need` (that isn't already cached at `bucket`) is
/// parsed (lazy) + rasterized at `scale` in parallel, then uploaded to the
/// texture cache. The single lazy-load / raster path both reading modes share
/// — keeps page work O(1)-per-visible-page regardless of paged vs continuous.
fn raster_upload(&self, ui: &mut egui::Ui, need_visible: &[usize], bucket: u32, scale: f32) {
let need: Vec<usize> = need_visible
.iter()
.copied()
.filter(|i| !self.cache.borrow().contains_key(&(*i, bucket)))
.collect();
if need.is_empty() {
return;
}
self.pages.borrow_mut().ensure_parsed(&need);
let rastered = self.pages.borrow().rasterize_many(&need, scale);
for (i, img) in rastered {
let handle = ui.ctx().load_texture(
format!("docview-{}-{}-{}", self.title, i, bucket),
img,
egui::TextureOptions::LINEAR,
);
self.cache.borrow_mut().insert((i, bucket), handle);
}
}
/// Paint one page into `rect` with the reader chrome: a soft drop shadow, the
/// white paper, the rasterized page (from the cache, if ready), and a thin
/// border — an accent border when this page is the focused search hit. The
/// premium look every page gets in both reading modes.
fn draw_page(&self, ui: &egui::Ui, page_idx: usize, rect: egui::Rect, bucket: u32, th: &Theme, active_hit: bool) {
let painter = ui.painter();
let radius = 3.0_f32;
// Soft drop shadow — a few faint, downward-offset rects behind the paper.
for i in 1..=5 {
let g = i as f32;
let sr = egui::Rect::from_min_max(
rect.min + egui::vec2(-g * 0.4, g * 0.7),
rect.max + egui::vec2(g * 0.4, g * 1.1),
);
painter.rect_filled(sr, radius, egui::Color32::from_black_alpha(9));
}
// Paper (so a not-yet-rasterized page shows as a clean sheet, not a hole).
painter.rect_filled(rect, radius, egui::Color32::WHITE);
if let Some(tex) = self.cache.borrow().get(&(page_idx, bucket)) {
painter.image(
tex.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
let (col, w) = if active_hit {
(th.accent, 2.0_f32)
} else {
(th.panel_stroke.gamma_multiply(0.5), 1.0_f32)
};
painter.rect_stroke(rect, radius, egui::Stroke::new(w, col), egui::StrokeKind::Outside);
}
/// **FC-9 pure view** — paints the toolbar, TOC sidebar, and continuous page
/// stack, and **returns** the [`Msg`]s this frame's interactions produced
/// (toggle/zoom/fit buttons, jump-box Enter, TOC clicks, and the scroll-derived
/// [`Msg::SyncViewport`]). It does **not** mutate the observable [`DocState`];
/// the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies
/// the returned messages through [`update`](DocView::update). It DOES advance
/// the render-engine cells (texture cache, lazy page parse/eviction,
/// smooth-scroll) through interior mutability — the honest residual impurity a
/// GPU-backed viewer can't express as a pure Model transition.
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let th = theme(ui);
let ppp = ui.ctx().pixels_per_point();
if self.pages.borrow().is_empty() {
ui.allocate_space(egui::vec2(ui.available_width(), 0.0));
ui.centered_and_justified(|ui| {
ui.label(egui::RichText::new("no document loaded").color(th.text_dim));
});
// ── render-lane emit: empty-document placeholder path RAN ─────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-docview::DocView::view",
"ui_render",
true, // empty-data early return: legitimately nothing to paint.
"drew=empty_hint pages=0",
);
return msgs;
}
// ── top controls: sidebar, mode, zoom, fit, page jump, search ──
let mut do_fit_width = false;
let mut do_fit_page = false;
let page_count = self.pages.borrow().len();
ui.horizontal_wrapped(|ui| {
if ui.button("☰").on_hover_text("Toggle contents sidebar").clicked() {
msgs.push(Msg::ToggleSidebar);
}
// Reading-mode toggle: continuous scroll ⇄ one page at a time.
let (mode_glyph, mode_hint) = if self.state.continuous {
("⭳ continuous", "Switch to paged reading (one page at a time)")
} else {
("▤ paged", "Switch to continuous scrolling")
};
if ui.button(mode_glyph).on_hover_text(mode_hint).clicked() {
msgs.push(Msg::ToggleMode);
}
ui.separator();
if ui.button("−").on_hover_text("Zoom out").clicked() {
msgs.push(Msg::ZoomOut);
}
ui.label(format!("{:.0}%", self.state.zoom * 100.0));
if ui.button("+").on_hover_text("Zoom in").clicked() {
msgs.push(Msg::ZoomIn);
}
if ui.button("fit width").clicked() {
do_fit_width = true;
}
if ui.button("fit page").clicked() {
do_fit_page = true;
}
ui.separator();
// Paged mode gets explicit prev/next page buttons (continuous scrolls).
if !self.state.continuous {
if ui.button("◀").on_hover_text("Previous page").clicked() {
msgs.push(Msg::JumpTo(self.state.current_page.saturating_sub(1)));
}
if ui.button("▶").on_hover_text("Next page").clicked() {
msgs.push(Msg::JumpTo(self.state.current_page + 1));
}
}
ui.label("page");
// Pure view: edit a CLONE of the (render-only) jump buffer, emit a
// `JumpTo` on Enter, and write the edited text back through the cell.
let mut buf = self.jump_buf.borrow().clone();
let resp = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(40.0));
if resp.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
&& let Ok(n) = buf.trim().parse::<usize>()
&& n >= 1
{
msgs.push(Msg::JumpTo(n - 1));
}
*self.jump_buf.borrow_mut() = buf;
ui.label(format!("/ {page_count}"));
});
// ── search row: query box + hit navigation, honest about availability ──
ui.horizontal_wrapped(|ui| {
ui.label("🔎");
let mut q = self.state.query.clone();
let resp = ui.add(
egui::TextEdit::singleline(&mut q)
.desired_width(220.0)
.hint_text(if self.search_available() {
"search the manual…"
} else {
"no searchable text in this book"
}),
);
if resp.changed() {
msgs.push(Msg::SetQuery(q.clone()));
}
// Enter in the search box jumps to the next hit.
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
msgs.push(Msg::NextHit);
}
let has_query = !self.state.query.trim().is_empty();
if has_query {
let hits = self.state.search_hits.len();
if ui.button("◀").on_hover_text("Previous match").clicked() {
msgs.push(Msg::PrevHit);
}
if ui.button("▶").on_hover_text("Next match").clicked() {
msgs.push(Msg::NextHit);
}
if ui.button("✕").on_hover_text("Clear search").clicked() {
msgs.push(Msg::ClearSearch);
}
let label = if hits == 0 {
"no matches".to_string()
} else {
format!("match {} of {}", self.state.active_hit + 1, hits)
};
ui.label(egui::RichText::new(label).color(th.text_dim));
} else {
ui.label(
egui::RichText::new(format!("page {} of {page_count}", self.state.current_page + 1))
.color(th.text_dim),
);
}
});
ui.separator();
// Derived-from-scroll values hoisted out of the body closure so the
// testmatrix emit below reports THIS frame's geometry (not last frame's).
let mut synced_current = self.state.current_page;
let mut synced_visible: Vec<usize> = Vec::new();
// ── body: optional sidebar + central continuous scroll ──
let avail = ui.available_size();
ui.horizontal_top(|ui| {
if self.state.show_sidebar {
// The sidebar is a VERTICAL stack (Contents label, separator, then one
// TOC row per entry). We're inside `horizontal_top`, and both
// `allocate_ui` and egui's `ScrollArea` content inherit the *parent*
// layout — so without forcing top-down here the whole TOC lays out
// left-to-right and collapses to a single line (the "index is one line
// only" bug). Pin the column to top-down so each entry gets its own row.
let sidebar_layout = egui::Layout::top_down(egui::Align::Min);
let sidebar_size = egui::vec2((avail.x * 0.22).clamp(120.0, 280.0), avail.y);
ui.allocate_ui_with_layout(sidebar_size, sidebar_layout, |ui| {
egui::Frame::default().fill(th.panel_bg).inner_margin(8.0).show(ui, |ui| {
ui.set_min_height(avail.y);
self.draw_sidebar(ui, &th, &mut msgs);
});
});
ui.separator();
}
// SOLID (non-floating) scroll bars: the vertical bar gets its OWN lane and
// never overlays the page's right edge. egui's default FLOATING bar floats
// on top of the content, covering the rightmost sliver — that was the
// "rightmost character is half-width / page clipped too early" bug. Reserve
// the bar's width consistently for both fit-width and the layout floor
// (this replaces the old magic `- 16.0`).
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
let bar_w = {
let s = &ui.spacing().scroll;
s.bar_width + s.bar_inner_margin + s.bar_outer_margin
};
// fit-width / fit-page use the central column width MINUS the reserved
// scrollbar lane; fit-page also folds in the body viewport height.
let column_width = ui.available_width();
let content_width = (column_width - bar_w).max(1.0);
if do_fit_width {
msgs.push(Msg::FitWidth(content_width));
}
if do_fit_page {
msgs.push(Msg::FitPage(content_width, (avail.y - bar_w).max(1.0)));
}
let bucket = self.zoom_bucket(ppp);
let scale = bucket as f32 / 50.0;
let n = self.pages.borrow().len();
// The page the focused search hit lives on — drawn with an accent frame.
let active_hit_page = self.active_hit_page();
if self.state.continuous {
// ══ CONTINUOUS — the whole page stack, lazily rasterized band-by-band.
// Pre-compute each page's logical box + its top offset in the stack.
let mut tops = Vec::with_capacity(n);
let mut sizes = Vec::with_capacity(n);
let mut y = 0.0f32;
for i in 0..n {
let s = self.page_logical_size(i);
tops.push(y);
sizes.push(s);
y += s.y + PAGE_GAP;
}
let pending = self.state.scroll_to;
// Smooth-scroll the page stack (SCRL-3): force egui's *displayed* offset
// to our eased offset, read back its resolved offset (after this frame's
// wheel) as the new target, and `advance` by `stable_dt` (FC-7). A
// programmatic page jump SNAPS the eased offset to the page top.
let dt = ui.input(|i| i.stable_dt);
let (mut psx, mut psy) = (self.page_scroll_x.get(), self.page_scroll_y.get());
if let Some(page) = pending {
let top = tops[page.min(n - 1)];
psy.offset = top;
psy.target = top;
psy.velocity = 0.0;
}
let scroll = egui::ScrollArea::both()
.id_salt("docview-pages")
.vertical_scroll_offset(psy.offset)
.horizontal_scroll_offset(psx.offset);
let out = scroll.show_viewport(ui, |ui, viewport| {
// Themed backdrop behind the paper (light/dark aware).
ui.painter().rect_filled(ui.clip_rect(), 0.0, th.bg);
// The viewport band (+ margin + velocity-biased prefetch lead) →
// which pages to rasterize. Only these load textures; the rest are
// skipped + evicted, so page work stays O(visible) not O(all).
let margin = viewport.height() * VIEWPORT_MARGIN_FACTOR;
let velocity = viewport.min.y - self.last_scroll_y.get();
self.last_scroll_y.set(viewport.min.y);
let lead = (velocity.abs() * PREFETCH_LEAD).clamp(0.0, viewport.height() * 2.0);
let (lead_up, lead_down) = if velocity >= 0.0 { (0.0, lead) } else { (lead, 0.0) };
let band_top = viewport.min.y - margin - lead_up;
let band_bot = viewport.max.y + margin + lead_down;
let view_centre = (viewport.min.y + viewport.max.y) * 0.5;
// Reserve the full stacked height so the scrollbar is correct.
let total_w = sizes.iter().fold(0.0f32, |m, s| m.max(s.x));
let lane_w = total_w.max(content_width);
ui.set_min_size(egui::vec2(lane_w, y.max(1.0)));
// Pass 1: geometry — which pages are on-screen (centred in the lane)
// and which is the current (centremost) page.
let origin = ui.min_rect();
let mut visible: Vec<(usize, egui::Rect)> = Vec::new();
let mut best_page = 0usize;
let mut best_dist = f32::MAX;
for i in 0..n {
let p_top = tops[i];
let p_bot = p_top + sizes[i].y;
let p_centre = (p_top + p_bot) * 0.5;
let dist = (p_centre - view_centre).abs();
if dist < best_dist {
best_dist = dist;
best_page = i;
}
if p_bot >= band_top && p_top <= band_bot {
let x = origin.left() + ((lane_w - sizes[i].x) * 0.5).max(0.0);
let rect = egui::Rect::from_min_size(
egui::pos2(x, origin.top() + p_top),
sizes[i],
);
visible.push((i, rect));
}
}
let visible_idx: Vec<usize> = visible.iter().map(|(i, _)| *i).collect();
// Pass 2: lazily fetch+parse+rasterize the band (parallel, shared
// path). Pass 3: paint each page with the reader chrome (shadow +
// paper + border + search-hit accent).
self.raster_upload(ui, &visible_idx, bucket, scale);
for (i, rect) in &visible {
self.draw_page(ui, *i, *rect, bucket, &th, Some(*i) == active_hit_page);
}
*self.jump_buf.borrow_mut() = (best_page + 1).to_string();
self.evict(&visible_idx, bucket);
(best_page, visible_idx)
});
let content_size = out.content_size;
let inner_rect = out.inner_rect;
let state_offset = out.state.offset;
let (best_page, visible_idx) = out.inner;
synced_current = best_page;
synced_visible = visible_idx.clone();
// FC-9: the derived scroll state mutates the Model only via `update`.
msgs.push(Msg::SyncViewport {
current_page: best_page,
visible: visible_idx,
consumed_scroll_to: pending.is_some(),
});
// Update the eased scroll: extents from the laid-out content, then
// follow egui's resolved (wheel) offset / glide to a pending jump.
let max_y = (content_size.y - inner_rect.height()).max(0.0);
let max_x = (content_size.x - inner_rect.width()).max(0.0);
let mut animating = psy.follow(max_y, state_offset.y, dt);
animating |= psx.follow(max_x, state_offset.x, dt);
if animating {
ui.ctx().request_repaint();
}
self.page_scroll_x.set(psx);
self.page_scroll_y.set(psy);
} else {
// ══ PAGED — one page centred; ◀/▶ + the jump box move between pages.
// Still lazy: only the current page ± its neighbours are ever parsed /
// rasterized (neighbours kept warm so next/prev doesn't re-raster).
let cur = self.state.current_page.min(n - 1);
let size = self.page_logical_size(cur);
let keep: Vec<usize> = [cur.saturating_sub(1), cur, (cur + 1).min(n - 1)]
.into_iter()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
egui::ScrollArea::both().id_salt("docview-paged").show(ui, |ui| {
ui.painter().rect_filled(ui.clip_rect(), 0.0, th.bg);
let lane_w = size.x.max(content_width);
ui.set_min_size(egui::vec2(lane_w, size.y.max(1.0)));
let origin = ui.min_rect();
let x = origin.left() + ((lane_w - size.x) * 0.5).max(0.0);
let rect = egui::Rect::from_min_size(egui::pos2(x, origin.top()), size);
// Warm current + neighbours, draw the current page, bound memory.
self.raster_upload(ui, &keep, bucket, scale);
self.draw_page(ui, cur, rect, bucket, &th, Some(cur) == active_hit_page);
self.evict(&keep, bucket);
});
*self.jump_buf.borrow_mut() = (cur + 1).to_string();
synced_current = cur;
synced_visible = vec![cur];
msgs.push(Msg::SyncViewport {
current_page: cur,
visible: vec![cur],
consumed_scroll_to: self.state.scroll_to.is_some(),
});
}
});
// ── render-lane emit: this pure-view path RAN ─────────────────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-docview::DocView::view",
"ui_render",
// OK = the continuous-scroll pass drew real geometry: the visible band is
// a subset of the deck and the current page is a valid page index.
synced_visible.len() <= self.pages.borrow().len() && synced_current < self.pages.borrow().len(),
&format!(
"pages={} current={} visible={} cached={}",
self.pages.borrow().len(),
synced_current,
synced_visible.len(),
self.cache.borrow().len(),
),
);
msgs
}
}
// ── typed copy (§16) — the document outline (TOC) as Text ─────────────────────
// The pages are rendered SVG rasters (no extractable body text), but the table of
// contents IS textual and is what a reader would copy to cite a section.
impl CopySource for DocView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
if self.state.toc.is_empty() {
return None;
}
let text = self
.state
.toc
.iter()
.map(|(title, page)| format!("{}\t{}", title, page + 1))
.collect::<Vec<_>>()
.join("\n");
Some(ClipPayload::Text(text))
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for DocView {
type Model = DocState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &DocState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
DocView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
DocView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for DocView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`). **Form 3**
// (`custom_state_json`) because this viewer publishes a **richer** `state_json`
// than plain `serde(state())`: it folds in the non-Model, non-serializable engine's
// derived signals — `page_count` / `cache_size` / `page_sizes_pt` (from the parsed
// [`Pages`] + texture cache) — alongside the Model's `current_page` / `zoom` /
// `sidebar` / `toc` / `visible_pages`. These exact keys are read by facett-demo's
// mega_matrix + robot_demo, so they are preserved verbatim. The extra block also
// re-exposes typed copy, the scalable/themeable/resizable/copyable caps, the
// zoom-as-scale bridge, and `as_any_mut` (host downcast).
facett_core::impl_facet_via_elm!(DocView, custom_state_json, {
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"page_count": self.pages.borrow().len(),
"current_page": self.state.current_page,
"zoom": self.state.zoom,
"sidebar": self.state.show_sidebar,
"toc": self.state.toc.iter().map(|(t, p)| serde_json::json!({ "title": t, "page": p })).collect::<Vec<_>>(),
"visible_pages": self.state.visible_pages,
"cache_size": self.cache.borrow().len(),
"page_sizes_pt": self.pages.borrow().sizes_pt(),
// Reading mode + search state — a robot asserts these.
"continuous": self.state.continuous,
"mode": if self.state.continuous { "continuous" } else { "paged" },
"query": self.state.query,
"search_hits": self.state.search_hits,
"active_hit": self.state.active_hit,
"search_available": self.search_available(),
})
}
/// Honors the uniform `scale` (drives zoom through the deck's Ctrl-+/-/0), reads
/// the active [`Theme`] (sidebar/background/text), and refits to any host size.
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.scalable().themeable().resizable().copyable()
}
/// The uniform scale IS the zoom — so the deck's zoom toolbar/shortcuts drive
/// the same value `fit_width`/`zoom_in` do.
fn scale(&self) -> f32 {
self.state.zoom
}
fn set_scale(&mut self, scale: f32) {
self.set_zoom(scale);
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
});
#[cfg(test)]
mod tests {
/// The airgap font registers under the family name the parse options name, in BOTH
/// feature states — a mismatch would leave usvg falling back to nothing and dropping
/// every `<text>` (the failure the DejaVu const guard exists for).
#[test]
fn the_embedded_font_registers_under_the_family_it_is_asked_for_by() {
let mut db = usvg::fontdb::Database::new();
let ids = db.load_font_source(usvg::fontdb::Source::Binary(std::sync::Arc::new(embedded_sans())));
assert!(!ids.is_empty(), "the embedded font parses");
let fams: Vec<String> = ids.iter().flat_map(|id| db.face(*id).unwrap().families.iter().map(|f| f.0.clone())).collect();
assert!(fams.iter().any(|f| f == EMBEDDED_SANS_FAMILY), "{EMBEDDED_SANS_FAMILY:?} not in {fams:?}");
// And a real <text> shapes against it with no system fonts at all.
let page = DocPage::from_svg_bytes(br#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="40"><text x="4" y="24" font-size="16">airgap</text></svg>"#)
.expect("parses");
assert!(page.text().contains("airgap"), "the text layer survived: {:?}", page.text());
}
use super::*;
/// A minimal, valid 1-page SVG of `w`×`h` pt with a black rectangle filling
/// most of it — so rasterizing it yields plenty of non-blank (opaque) pixels.
/// This is the "Typst-outlined" shape: pure vector paths, no fonts.
fn synthetic_page(w: u32, h: u32, fill: &str) -> String {
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">
<rect x="0" y="0" width="{w}" height="{h}" fill="white"/>
<rect x="{rx}" y="{ry}" width="{rw}" height="{rh}" fill="{fill}"/>
</svg>"#,
rx = w / 10,
ry = h / 10,
rw = w * 8 / 10,
rh = h * 8 / 10,
)
}
/// A synthetic 2-page document + a TOC pointing at each page.
#[test]
fn typed_copy_is_the_toc_as_text() {
use facett_core::clip::{ClipKind, CopySource};
let v = two_page_doc();
let p = v.copy_payload().expect("a doc with a TOC copies its outline");
assert_eq!(p.kind(), ClipKind::Text);
// 1-based page numbers in the copied outline.
assert_eq!(p.as_text(), "Introduction\t1\nChapter 2\t2");
// An empty doc offers nothing to copy.
assert!(DocView::new("empty", vec![], vec![]).copy_payload().is_none());
}
fn two_page_doc() -> DocView {
let p1 = synthetic_page(200, 280, "black");
let p2 = synthetic_page(200, 280, "#204080");
let pages = vec![
DocPage::from_svg_bytes(p1.as_bytes()).expect("page 1 parses"),
DocPage::from_svg_bytes(p2.as_bytes()).expect("page 2 parses"),
];
let toc = vec![("Introduction".to_string(), 0), ("Chapter 2".to_string(), 1)];
DocView::new("manual", pages, toc)
}
/// A 1-page SVG carrying a single real `<text>` word (searchable text layer).
fn word_page(word: &str) -> String {
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="80" viewBox="0 0 200 80">
<rect x="0" y="0" width="200" height="80" fill="white"/>
<text x="10" y="55" font-size="32" fill="black">{word}</text>
</svg>"#
)
}
/// A 3-page doc whose pages carry the real `<text>` words alpha/beta/gamma.
fn text_doc() -> DocView {
let pages = vec![
DocPage::from_svg_bytes(word_page("alpha").as_bytes()).unwrap(),
DocPage::from_svg_bytes(word_page("beta").as_bytes()).unwrap(),
DocPage::from_svg_bytes(word_page("gamma beta").as_bytes()).unwrap(),
];
let toc = vec![
("Alpha".to_string(), 0),
("Beta".to_string(), 1),
("Gamma".to_string(), 2),
];
DocView::new("searchable", pages, toc)
}
#[test]
fn extracts_real_text_layer_but_not_outlined_pages() {
// A real-`<text>` page yields its words as a searchable text layer …
let p = DocPage::from_svg_bytes(word_page("Hello World").as_bytes()).unwrap();
assert_eq!(p.text(), "Hello World", "real <text> is extracted");
// … a glyph-outlined / path-only page (Typst's shape) has NO text layer.
let outlined = DocPage::from_svg_bytes(synthetic_page(100, 100, "black").as_bytes()).unwrap();
assert!(outlined.text().is_empty(), "outlined page has no searchable text");
}
#[test]
fn search_finds_pages_and_navigates_hits() {
let mut v = text_doc();
assert!(v.search_available(), "the book carries a real text layer");
// "beta" appears on pages 1 and 2 (0-based).
v.set_query("beta");
assert_eq!(v.search_hits(), &[1, 2], "both beta pages are hits");
assert_eq!(v.state_json()["search_hits"], serde_json::json!([1, 2]));
// next/prev cycle the focused hit and move the current page to it.
v.next_hit();
assert_eq!(v.current_page(), 1, "first Next lands on the first hit page");
v.next_hit();
assert_eq!(v.current_page(), 2, "second Next lands on the second hit page");
v.next_hit();
assert_eq!(v.current_page(), 1, "Next wraps back to the first hit");
v.prev_hit();
assert_eq!(v.current_page(), 2, "Prev wraps to the last hit");
// Case-insensitive; a miss clears the hit set (honest — never a fake hit).
v.set_query("ALPHA");
assert_eq!(v.search_hits(), &[0], "search is case-insensitive");
v.set_query("nomatch");
assert!(v.search_hits().is_empty(), "a non-matching query yields no hits");
v.clear_search();
assert!(v.query().is_empty() && v.search_hits().is_empty(), "clear resets search");
}
#[test]
fn search_unavailable_for_outlined_book_is_honest() {
// A book whose pages are ALL glyph-outlined (Typst's real shape) has no
// text layer — search is honestly unavailable, never faked.
let mut v = two_page_doc(); // rect-only synthetic pages, no <text>
assert!(!v.search_available(), "outlined book reports no searchable text");
v.set_query("anything");
assert!(v.search_hits().is_empty(), "no hits without a text layer");
assert_eq!(v.state_json()["search_available"], serde_json::json!(false));
}
#[test]
fn reading_mode_toggles_and_reports_in_state() {
let mut v = two_page_doc();
assert!(v.is_continuous(), "continuous by default");
assert_eq!(v.state_json()["mode"], "continuous");
v.toggle_mode();
assert!(!v.is_continuous(), "toggle flips to paged");
assert_eq!(v.state_json()["mode"], "paged");
assert_eq!(v.state_json()["continuous"], serde_json::json!(false));
v.set_continuous(true);
assert!(v.is_continuous(), "set_continuous restores continuous");
}
#[test]
fn minimal_page_source_impl_still_compiles_and_runs() {
// Regression / API-compat guard for nornir's `ZnippyManual`, which impls
// `PageSource` with ONLY `page_count` + `page_svg` (default `page_size_hint`
// AND the new default `page_text`). This mirrors that exact minimal impl to
// prove the trait's `page_text` addition is backward-compatible: a source
// that predates it keeps compiling and driving the viewer (search is simply
// unavailable for it — the honest limitation for an outlined book).
use facett_core::harness;
struct Minimal;
impl PageSource for Minimal {
fn page_count(&self) -> usize {
4
}
fn page_svg(&self, _i: usize) -> Option<Vec<u8>> {
Some(synthetic_page(200, 280, "black").into_bytes())
}
}
let mut v = DocView::from_source("znippy-like", Box::new(Minimal), vec![("A".into(), 0)]);
assert_eq!(v.page_count(), 4);
// Outlined + no text sidecar → search honestly unavailable.
assert!(!v.search_available());
let r = harness::headless_render(&mut v);
assert_eq!(r.state["page_count"], 4);
assert_eq!(r.state["search_available"], serde_json::json!(false));
}
#[test]
fn fit_page_fits_whole_page_within_bounds() {
let mut v = two_page_doc(); // 200×280 pt pages
// Height-bound: fit 400×280 → min(400/200, 280/280) = 1.0.
let z = v.fit_page(egui::vec2(400.0, 280.0));
assert!((z - 1.0).abs() < 1e-5, "fit-page is the smaller of w/h fit, got {z}");
// Width-bound: fit 200×840 → min(200/200, 840/280) = 1.0 as well; use 100 wide.
let z = v.fit_page(egui::vec2(100.0, 840.0));
assert!((z - 0.5).abs() < 1e-5, "width-bound fit-page clamps to MIN_ZOOM 0.5, got {z}");
}
#[test]
fn parses_pages_and_reads_intrinsic_size() {
let p = DocPage::from_svg_bytes(synthetic_page(200, 280, "black").as_bytes()).unwrap();
assert_eq!(p.width_pt(), 200.0, "intrinsic width read from the SVG");
assert_eq!(p.height_pt(), 280.0, "intrinsic height read from the SVG");
}
#[test]
fn rasterizes_to_a_non_blank_pixmap() {
// INJECT a real page, rasterize at 2× px-per-pt, ASSERT the pixmap has
// actual coloured (non-white, opaque) pixels — proof it rendered the path,
// not a blank canvas.
let p = DocPage::from_svg_bytes(synthetic_page(100, 100, "black").as_bytes()).unwrap();
let img = p.rasterize(2.0).expect("rasterizes");
assert_eq!(img.size, [200, 200], "100pt × 2× = 200px");
let black = egui::Color32::from_rgb(0, 0, 0);
let opaque = img.pixels.iter().filter(|c| c.a() == 255).count();
let dark = img.pixels.iter().filter(|&&c| c == black).count();
assert!(opaque > 0, "page rasterized to opaque pixels");
assert!(dark > 1000, "the black rectangle covers a big block of pixels, got {dark}");
}
/// A 1-page SVG carrying a real `<text>` element (not outlined) — the shape
/// skade's diagram SVGs take. This is exactly what usvg DROPS when its
/// `Options.fontdb` is empty (the old default-Options bug).
fn text_page() -> String {
// Black text on a white page. No `font-family` is given, so resolution
// falls back to the parse options' default family → the embedded font.
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="80" viewBox="0 0 200 80">
<rect x="0" y="0" width="200" height="80" fill="white"/>
<text x="10" y="55" font-size="48" fill="black">HELLO</text>
</svg>"#
.to_string()
}
/// Count opaque, near-black pixels in a rasterized page — the rendered "ink".
/// Zero ink on a black-text-on-white page = the text never painted.
fn dark_ink(img: &egui::ColorImage) -> usize {
img.pixels
.iter()
.filter(|c| c.a() == 255 && c.r() < 128 && c.g() < 128 && c.b() < 128)
.count()
}
/// Does this group (recursively) contain a `<text>` node usvg kept?
fn has_text_node(group: &usvg::Group) -> bool {
group.children().iter().any(|node| match node {
usvg::Node::Text(_) => true,
usvg::Node::Group(g) => has_text_node(g),
_ => false,
})
}
#[test]
fn svg_text_is_retained_and_renders_ink() {
// REGRESSION (text-loss bug): skade-style `<text>` SVGs must keep their
// text. With the OLD path (`usvg::Options::default()`, empty fontdb) usvg
// DROPS the unshapeable `<text>` entirely (verified: no Text node, 0 ink);
// with the fontdb wired in, the glyphs shape against the embedded font, the
// Text node survives the parse, and it paints real black ink.
let p = DocPage::from_svg_bytes(text_page().as_bytes()).expect("text SVG parses");
// 1) The parsed tree retains the `<text>` (dropped under the empty fontdb).
assert!(
has_text_node(p.tree.root()),
"the <text> survived parsing (it is dropped with the empty-fontdb default Options)"
);
// 2) Rasterizing paints real dark ink — not a blank white canvas.
let img = p.rasterize(2.0).expect("rasterizes");
assert!(
dark_ink(&img) > 200,
"the HELLO text rendered visible ink (dark pixels); got {} \
(0 under the empty-fontdb default Options — the bug)",
dark_ink(&img)
);
}
#[test]
fn empty_fontdb_default_options_drops_text_proving_the_bug() {
// Pins WHY the fix is needed: parsing + rendering the SAME text SVG through
// the OLD path — `usvg::Options::default()`, whose fontdb is empty — drops
// the unshapeable `<text>` (no Text node) and paints ZERO ink (a blank
// page), which is exactly the invisible-text bug. The fixed `from_svg_bytes`
// (above) keeps + paints it. If a future usvg renders text without a fontdb
// this flips and flags that the fix is moot.
let tree =
usvg::Tree::from_data(text_page().as_bytes(), &usvg::Options::default()).expect("parses");
assert!(
!has_text_node(tree.root()),
"the empty-fontdb default Options drops the <text> node (the bug)"
);
// And it paints nothing: render at 2× and assert zero dark ink.
let mut pm = tiny_skia::Pixmap::new(400, 160).expect("pixmap");
resvg::render(&tree, tiny_skia::Transform::from_scale(2.0, 2.0), &mut pm.as_mut());
let dark = pm
.pixels()
.iter()
.filter(|p| {
let c = p.demultiply();
c.alpha() == 255 && c.red() < 128 && c.green() < 128 && c.blue() < 128
})
.count();
assert_eq!(dark, 0, "default Options renders the text invisibly (0 ink); got {dark}");
}
#[test]
fn rasterize_clamps_to_max_tex_dim() {
// A huge page × a huge scale would blow past MAX_TEX_DIM; assert it clamps.
let p = DocPage::from_svg_bytes(synthetic_page(2000, 2000, "black").as_bytes()).unwrap();
let img = p.rasterize(100.0).expect("rasterizes (clamped)");
assert!(img.size[0] as u32 <= MAX_TEX_DIM && img.size[1] as u32 <= MAX_TEX_DIM, "clamped to MAX_TEX_DIM");
}
#[test]
fn state_json_reports_pages_toc_and_zoom() {
let v = two_page_doc();
let j = v.state_json();
assert_eq!(j["page_count"], 2);
assert_eq!(j["current_page"], 0);
assert_eq!(j["zoom"], 1.0);
let toc = j["toc"].as_array().unwrap();
assert_eq!(toc.len(), 2);
assert_eq!(toc[0]["title"], "Introduction");
assert_eq!(toc[1]["page"], 1);
assert_eq!(j["page_sizes_pt"][0], serde_json::json!([200.0, 280.0]));
}
// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
// harness and assert the resulting `DocState` — no egui, no GPU. ───────────
#[test]
fn harness_snapshot_drives_zoom_jump_and_sidebar() {
use facett_core::harness;
let mut v = two_page_doc();
// Zoom in one step, jump to page 2 (index 1), toggle the sidebar off —
// applied purely via `Elm::update`, no render in the loop.
let snap = harness::snapshot(&mut v, [Msg::ZoomIn, Msg::JumpTo(1), Msg::ToggleSidebar]);
assert!((snap.zoom - ZOOM_STEP).abs() < 1e-5, "ZoomIn stepped the zoom headlessly");
assert_eq!(snap.current_page, 1, "JumpTo moved the current page in the snapshot");
assert_eq!(snap.scroll_to, Some(1), "JumpTo queued a scroll-to the target page");
assert!(!snap.show_sidebar, "ToggleSidebar flipped the sidebar off");
// The snapshot is a clone of the live state — same transition, headlessly.
assert_eq!(&snap, v.state());
}
#[test]
fn harness_setzoom_and_fitwidth_clamp_and_compute() {
use facett_core::harness;
let mut v = two_page_doc(); // 200pt-wide pages
// SetZoom clamps above MAX; FitWidth = content / page_width, clamped.
let snap = harness::snapshot(&mut v, [Msg::SetZoom(99.0)]);
assert_eq!(snap.zoom, MAX_ZOOM, "SetZoom clamps at MAX_ZOOM");
let snap = harness::snapshot(&mut v, [Msg::FitWidth(400.0)]);
assert!((snap.zoom - 2.0).abs() < 1e-5, "FitWidth = 400/200 = 2.0");
// Stepping past the bounds clamps at both ends.
let snap = harness::snapshot(&mut v, std::iter::repeat_n(Msg::ZoomIn, 20));
assert_eq!(snap.zoom, MAX_ZOOM, "ZoomIn clamps at MAX_ZOOM");
let snap = harness::snapshot(&mut v, std::iter::repeat_n(Msg::ZoomOut, 40));
assert_eq!(snap.zoom, MIN_ZOOM, "ZoomOut clamps at MIN_ZOOM");
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut v = two_page_doc();
// FC-8: docview does no host I/O, so the effect stream is always empty.
let effects = harness::drive(&mut v, [Msg::ZoomIn, Msg::JumpTo(1), Msg::ToggleSidebar]);
assert!(effects.is_empty(), "FC-8: docview emits no Effects");
// FC-3: the observable Model round-trips through serde.
let json = serde_json::to_value(v.state()).unwrap();
assert_eq!(json["current_page"], 1);
let back: DocState = serde_json::from_value(json).unwrap();
assert_eq!(&back, v.state(), "serde(state) -> state round-trips (FC-3)");
}
#[test]
fn syncviewport_msg_updates_current_and_visible_and_consumes_scroll_to() {
use facett_core::harness;
let mut v = two_page_doc();
// A JumpTo queues scroll_to; the view's SyncViewport (here fed directly)
// lands the derived page/visible band AND consumes the pending scroll-to.
let snap = harness::snapshot(
&mut v,
[
Msg::JumpTo(1),
Msg::SyncViewport { current_page: 1, visible: vec![0, 1], consumed_scroll_to: true },
],
);
assert_eq!(snap.current_page, 1);
assert_eq!(snap.visible_pages, vec![0, 1]);
assert_eq!(snap.scroll_to, None, "SyncViewport consumed the pending scroll_to");
}
#[test]
fn jump_sets_current_page_and_state_reflects_it() {
// INJECT a jump to page 2 (index 1), ASSERT current_page + state_json move.
let mut v = two_page_doc();
assert_eq!(v.current_page(), 0);
v.jump_to(1);
assert_eq!(v.current_page(), 1, "jump moved the current page");
assert_eq!(v.state_json()["current_page"], 1, "state_json reports the jump");
// Out-of-range clamps to the last page, never panics.
v.jump_to(99);
assert_eq!(v.current_page(), 1, "clamped to the last page");
}
#[test]
fn zoom_steps_and_fit_width_clamp() {
let mut v = two_page_doc();
assert_eq!(v.zoom(), 1.0);
v.zoom_in();
assert!((v.zoom() - ZOOM_STEP).abs() < 1e-5, "one step in");
// Clamps at the top.
for _ in 0..20 {
v.zoom_in();
}
assert_eq!(v.zoom(), MAX_ZOOM, "clamped at MAX_ZOOM");
for _ in 0..40 {
v.zoom_out();
}
assert_eq!(v.zoom(), MIN_ZOOM, "clamped at MIN_ZOOM");
// fit_width: page is 200pt wide → fitting 400px → zoom 2.0.
let z = v.fit_width(400.0);
assert!((z - 2.0).abs() < 1e-5, "fit width = 400/200 = 2.0, got {z}");
}
#[test]
fn fit_width_leaves_scrollbar_room_no_right_clip() {
// Regression (owner 2026-06-22): the rightmost page column rendered at
// half width / "clipped too early" because the page was fit to the FULL
// panel width while the vertical scrollbar (floating by default) overlaid
// its right edge. The fix fits to `panel - reserved_bar` (and uses SOLID
// bars that take their own lane). Assert the fitted page's logical width
// never exceeds the content width, so its right edge stays LEFT of the bar.
let mut v = two_page_doc(); // 200pt-wide pages
let panel = 416.0_f32;
let bar = 16.0_f32; // a representative reserved scrollbar lane
let content = panel - bar;
v.fit_width(content);
let page_w = v.page_logical_size(0).x;
assert!(
page_w <= content + 1e-3,
"fitted page fits within the content width (page {page_w}, content {content})"
);
assert!(
page_w <= panel - bar + 1e-3,
"page right edge stays left of the scrollbar lane — no overlay clip"
);
}
#[test]
fn caps_advertise_scalable_themeable_resizable_and_scale_is_zoom() {
let mut v = two_page_doc();
let caps = v.caps();
assert!(caps.scalable && caps.themeable && caps.resizable);
// The uniform scale IS the zoom (deck Ctrl-+/-/0 → zoom).
assert_eq!(v.scale(), 1.0);
v.set_scale(1.5);
assert!((v.scale() - 1.5).abs() < 1e-5, "deck scale drives zoom");
assert!((v.zoom() - 1.5).abs() < 1e-5);
}
#[test]
fn headless_render_rasterizes_pages_and_reports_state() {
use facett_core::harness;
// INJECT a real 2-page doc, render it headlessly through the facett harness,
// ASSERT it drew (tessellated image vertices), cached page textures, marked
// pages visible, and reported the page count + current page in state_json.
let mut v = two_page_doc();
let r = harness::headless_render(&mut v);
assert_eq!(r.title, "manual");
assert!(r.drew(), "the page images tessellate to vertices");
assert_eq!(r.state["page_count"], 2);
assert_eq!(r.state["current_page"], 0);
let visible = r.state["visible_pages"].as_array().unwrap();
assert!(!visible.is_empty(), "at least the first page is in the viewport");
assert!(visible.iter().any(|p| p == &serde_json::json!(0)), "page 0 is visible");
assert!(r.state["cache_size"].as_u64().unwrap() >= 1, "rasterized page(s) are cached");
}
#[test]
fn headless_render_after_jump_reports_target_page_visible() {
use facett_core::harness;
// Jump to page 2, then render: the target page must be the current page AND
// appear in the visible set (the scroll-to landed it in the viewport).
let mut v = two_page_doc();
v.jump_to(1);
let r = harness::headless_render(&mut v);
assert_eq!(r.state["current_page"], 1, "current page is the jumped-to page after render");
let visible = r.state["visible_pages"].as_array().unwrap();
assert!(visible.iter().any(|p| p == &serde_json::json!(1)), "page 1 visible after the jump");
}
#[test]
fn from_svgs_skips_unparseable_pages() {
// Two good pages + one garbage → page_count == 2 (bad one skipped, no panic).
let good = synthetic_page(120, 160, "black");
let svgs: Vec<Vec<u8>> = vec![good.clone().into_bytes(), b"<not svg>".to_vec(), good.into_bytes()];
let v = DocView::from_svgs("m", svgs, vec![]);
assert_eq!(v.page_count(), 2, "the unparseable page is skipped");
}
#[test]
fn empty_doc_renders_a_hint_without_panicking() {
use facett_core::harness;
let mut v = DocView::new("empty", vec![], vec![]);
let r = harness::headless_render(&mut v);
assert_eq!(r.state["page_count"], 0);
// No pages → nothing cached, no visible pages.
assert_eq!(r.state["cache_size"], 0);
}
#[test]
fn lazy_source_fetches_only_the_visible_band_not_every_page() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use facett_core::harness;
// A PageSource that counts how many pages the viewer actually pulls.
struct CountingSource {
pages: Vec<String>,
fetched: Arc<AtomicUsize>,
}
impl PageSource for CountingSource {
fn page_count(&self) -> usize {
self.pages.len()
}
fn page_svg(&self, i: usize) -> Option<Vec<u8>> {
self.fetched.fetch_add(1, Ordering::SeqCst);
self.pages.get(i).map(|s| s.clone().into_bytes())
}
fn page_size_hint(&self) -> (f32, f32) {
(200.0, 280.0)
}
}
// 100 pages — far more than fit in a headless viewport's band.
let pages: Vec<String> = (0..100).map(|_| synthetic_page(200, 280, "black")).collect();
let fetched = Arc::new(AtomicUsize::new(0));
let mut v = DocView::from_source(
"lazy",
Box::new(CountingSource { pages, fetched: fetched.clone() }),
vec![("Intro".to_string(), 0), ("Mid".to_string(), 50)],
);
// The count comes from the source — NO page was parsed at construction.
assert_eq!(v.page_count(), 100);
assert_eq!(fetched.load(Ordering::SeqCst), 0, "from_source parses nothing up front");
let r = harness::headless_render(&mut v);
assert_eq!(r.state["page_count"], 100);
// Layout reserved all 100 page slots (from the size hint), so the
// scrollbar is correct before anything is fetched.
assert_eq!(r.state["page_sizes_pt"].as_array().unwrap().len(), 100);
// Only the visible band was fetched + parsed — NOT all 100.
let n = fetched.load(Ordering::SeqCst);
assert!(n > 0 && n < 100, "fetched only the visible band, got {n}/100");
// The visible band rasterized into the texture cache.
assert!(r.state["cache_size"].as_u64().unwrap() >= 1, "visible pages rasterized");
assert!(!r.state["visible_pages"].as_array().unwrap().is_empty());
}
#[test]
fn lazy_jump_then_render_fetches_the_target_page() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use facett_core::harness;
struct Src {
n: usize,
fetched: Arc<AtomicUsize>,
}
impl PageSource for Src {
fn page_count(&self) -> usize {
self.n
}
fn page_svg(&self, _i: usize) -> Option<Vec<u8>> {
self.fetched.fetch_add(1, Ordering::SeqCst);
Some(synthetic_page(200, 280, "#204080").into_bytes())
}
fn page_size_hint(&self) -> (f32, f32) {
(200.0, 280.0)
}
}
let fetched = Arc::new(AtomicUsize::new(0));
let mut v = DocView::from_source("lazy", Box::new(Src { n: 80, fetched: fetched.clone() }), vec![]);
// Jump near the end, then render: the target page must be current + visible,
// proving the lazy fetch follows a jump (not just the opening band).
v.jump_to(70);
let r = harness::headless_render(&mut v);
assert_eq!(r.state["current_page"], 70);
let visible = r.state["visible_pages"].as_array().unwrap();
assert!(visible.iter().any(|p| p == &serde_json::json!(70)), "page 70 fetched + visible after jump");
}
}