Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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");
    }
}