src/viz/manual_tab.rs
//! 📖 **Manual** tab — nornir's own documentation, rendered live in the viz.
//!
//! `nornir docs book --format svg` writes the whole-doc book as one crisp vector
//! SVG per page plus a `manifest.json` under `<repo>/docs/book-svg/` (see
//! [`crate::docs`]). This tab loads that on-disk contract into a
//! [`facett_docview::DocView`] — a pure-Rust (resvg/usvg) vector manual viewer
//! that parses + rasterizes pages **multi-core** (rayon) — so the manual is a
//! click away inside `nornir viz`, crisp at any zoom, no PDF reader.
//!
//! Loading is **lazy** (first time the tab is shown) and best-effort: a missing
//! `book-svg/` dir renders a one-line hint telling you to run `nornir docs book
//! --format svg`, never an error.
use std::path::{Path, PathBuf};
use eframe::egui::{self};
use facett_docview::{DocView, Facet};
/// 📖 The Manual tab: a lazily-loaded [`DocView`] over `docs/book-svg/`.
pub struct ManualTab {
/// The viewer, built on first show; `None` until then or on load failure.
view: Option<DocView>,
/// Whether a load was attempted (so we don't retry every frame).
loaded: bool,
/// Load error (shown as a hint), or `None`.
error: Option<String>,
/// The resolved `book-svg` dir (for `state_json` / the hint).
dir: Option<PathBuf>,
/// True when the rendered book is the BUILT-IN fallback (no generated book
/// was found on disk / embedded).
builtin: bool,
}
impl Default for ManualTab {
fn default() -> Self {
Self {
view: None,
loaded: false,
error: None,
dir: None,
builtin: false,
}
}
}
impl ManualTab {
pub fn new() -> Self {
Self::default()
}
/// Force a reload on next draw (e.g. after `docs book` regenerates the SVGs).
pub fn reload(&mut self) {
self.loaded = false;
self.view = None;
self.error = None;
self.builtin = false;
}
/// Whether the manual is loaded and has at least one page.
pub fn is_loaded(&self) -> bool {
self.view.as_ref().is_some_and(|v| v.page_count() > 0)
}
fn load(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
// `manual-embed`: the archive is baked into the binary (travels with
// `cargo install`). Materialize it once to the data dir, so the znippy
// resolver below finds it like any on-disk archive.
#[cfg(feature = "manual-embed")]
pluck_out_embedded();
// Preferred: a packed `book.znippy` → the LAZY viewer reading pages O(1)
// out of the archive (only the visible band is ever decompressed/parsed).
// Falls through to the raw page dir if no archive is found or it fails.
#[cfg(feature = "manual-znippy")]
if let Some(path) = resolve_book_znippy() {
match load_manual_znippy(&path) {
Ok(view) => {
self.dir = Some(path);
self.view = Some(view);
return;
}
Err(e) => eprintln!("⚠ manual: book.znippy load failed, trying page dir: {e}"),
}
}
match resolve_book_svg_dir() {
Some(dir) => {
self.dir = Some(dir.clone());
match load_manual(&dir) {
Ok(view) => self.view = Some(view),
// A present-but-unreadable dir falls back to the built-in book
// rather than dead-ending on an error.
Err(e) => {
eprintln!("⚠ manual: {e}; using the built-in fallback book");
self.load_builtin();
}
}
}
// No generated book anywhere (the common fresh-checkout / lean-install
// state — `docs/book-svg` + `docs/book.znippy` are gitignored, generated
// on demand). Render the BUILT-IN fallback book so the 📖 Manual tab is
// never a dead "not available" hint; `nornir docs book --format svg`
// regenerates the full manual and takes precedence next load.
None => self.load_builtin(),
}
}
/// Build a [`DocView`] from the [`BUILTIN_PAGES`] baked into the binary — the
/// always-available minimal manual (cover + what-is-nornir + how-to-regenerate).
fn load_builtin(&mut self) {
let bytes: Vec<Vec<u8>> = BUILTIN_PAGES
.iter()
.map(|s| s.as_bytes().to_vec())
.collect();
let toc = vec![
("Cover".to_string(), 0usize),
("What is nornir".to_string(), 1usize),
("The book pipeline".to_string(), 2usize),
];
self.dir = Some(PathBuf::from("<built-in>"));
self.builtin = true;
self.view = Some(DocView::from_svgs(
"nornir (built-in manual)".to_string(),
bytes,
toc,
));
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
self.load();
// EMIT-DOCTRINE (§selftest): the manual is loaded lazily on first draw — ok ⇔
// a DocView with at least one page materialized; a missing `book-svg/` (the
// "run `nornir docs book`" hint) is the honest RED empty-state.
#[cfg(feature = "testmatrix")]
crate::selftest::emit(
"viz/manual (egui draw)",
"manual_loaded",
self.is_loaded(),
&match (&self.view, &self.error) {
(Some(v), _) => format!("{} page(s)", v.page_count()),
(None, Some(e)) => e.clone(),
(None, None) => "loading…".to_string(),
},
);
if let Some(e) = &self.error {
ui.add_space(20.0);
ui.label(egui::RichText::new("📖 Manual not available").strong());
ui.label(e);
return;
}
match &mut self.view {
Some(v) => Facet::ui(v, ui),
None => {
ui.label("loading manual…");
}
}
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"dir": self.dir.as_ref().map(|p| p.display().to_string()),
"loaded": self.is_loaded(),
// True when the rendered book is the baked-in fallback (no generated
// book found) — the tab still renders, just the minimal built-in one.
"builtin": self.builtin,
"error": self.error,
"doc": self.view.as_ref().map(|v| v.state_json()),
})
}
}
/// The BUILT-IN fallback manual — three valid vector pages baked into the binary
/// so the 📖 Manual tab always renders a book, even on a fresh checkout / lean
/// install where the generated `docs/book-svg` + `docs/book.znippy` (both
/// gitignored, produced on demand by `nornir docs book --format svg`) are absent.
static BUILTIN_PAGES: [&str; 3] = [
// Page 1 — cover.
r##"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1130" viewBox="0 0 800 1130">
<rect width="800" height="1130" fill="#0e1116"/>
<rect x="0" y="0" width="800" height="8" fill="#5aa9e6"/>
<text x="64" y="300" font-family="sans-serif" font-size="72" font-weight="bold" fill="#e6edf3">nornir</text>
<text x="64" y="360" font-family="sans-serif" font-size="26" fill="#9aa7b4">the Norns' loom — release lineage, architecture,</text>
<text x="64" y="396" font-family="sans-serif" font-size="26" fill="#9aa7b4">benchmarks and CI woven across a Rust workspace</text>
<line x1="64" y1="440" x2="736" y2="440" stroke="#30363d" stroke-width="2"/>
<text x="64" y="500" font-family="sans-serif" font-size="22" fill="#c9d1d9">Built-in manual — rendered in the viz 📖 Manual tab</text>
<text x="64" y="540" font-family="sans-serif" font-size="20" fill="#9aa7b4">via facett-docview (pure-Rust resvg vector viewer).</text>
<text x="64" y="1060" font-family="sans-serif" font-size="18" fill="#6e7681">Full book: nornir docs book nornir --format svg</text>
</svg>"##,
// Page 2 — what is nornir.
r##"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1130" viewBox="0 0 800 1130">
<rect width="800" height="1130" fill="#ffffff"/>
<text x="64" y="96" font-family="sans-serif" font-size="34" font-weight="bold" fill="#0e1116">What is nornir</text>
<line x1="64" y1="118" x2="736" y2="118" stroke="#d0d7de" stroke-width="2"/>
<text x="64" y="176" font-family="sans-serif" font-size="20" fill="#24292f">nornir reads a Rust workspace and weaves its release lineage,</text>
<text x="64" y="208" font-family="sans-serif" font-size="20" fill="#24292f">dependency + call graphs, architecture wiring, benchmarks and CI</text>
<text x="64" y="240" font-family="sans-serif" font-size="20" fill="#24292f">into one Iceberg warehouse, then paints it in an egui viz.</text>
<text x="64" y="312" font-family="sans-serif" font-size="24" font-weight="bold" fill="#0e1116">Modes</text>
<text x="80" y="352" font-family="sans-serif" font-size="19" fill="#24292f">• FAT / embedded — one binary owns the local warehouse.</text>
<text x="80" y="384" font-family="sans-serif" font-size="19" fill="#24292f">• SERVER — a monitored daemon clones + republishes members.</text>
<text x="80" y="416" font-family="sans-serif" font-size="19" fill="#24292f">• THIN client — the viz talks to a server over gRPC.</text>
<text x="64" y="488" font-family="sans-serif" font-size="24" font-weight="bold" fill="#0e1116">Viz tabs</text>
<text x="80" y="528" font-family="sans-serif" font-size="19" fill="#24292f">🧬 nornir · 🧵 Timeline · 🏛 Architecture · 🚇 Metro · 🩸 Bloodstream</text>
<text x="80" y="560" font-family="sans-serif" font-size="19" fill="#24292f">🗂 Funnel · 🚀 Release · 🧪 Test · 📈 Bench · 🛡 Security</text>
<text x="80" y="592" font-family="sans-serif" font-size="19" fill="#24292f">⚒ Dwarves (build + CI forge) · 📦 Holger · 📖 Manual · 🤖 Claude</text>
</svg>"##,
// Page 3 — the book pipeline / regenerate.
r##"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1130" viewBox="0 0 800 1130">
<rect width="800" height="1130" fill="#ffffff"/>
<text x="64" y="96" font-family="sans-serif" font-size="34" font-weight="bold" fill="#0e1116">The book pipeline</text>
<line x1="64" y1="118" x2="736" y2="118" stroke="#d0d7de" stroke-width="2"/>
<text x="64" y="176" font-family="sans-serif" font-size="20" fill="#24292f">This 📖 Manual tab reads docs/book-svg/ (one vector SVG per page +</text>
<text x="64" y="208" font-family="sans-serif" font-size="20" fill="#24292f">manifest.json) through facett-docview. With the manual-embed feature</text>
<text x="64" y="240" font-family="sans-serif" font-size="20" fill="#24292f">a packed docs/book.znippy is baked into the binary so the manual</text>
<text x="64" y="272" font-family="sans-serif" font-size="20" fill="#24292f">survives cargo install.</text>
<text x="64" y="344" font-family="sans-serif" font-size="24" font-weight="bold" fill="#0e1116">Regenerate the full manual</text>
<rect x="64" y="368" width="672" height="52" rx="6" fill="#0e1116"/>
<text x="82" y="402" font-family="monospace" font-size="19" fill="#7ee787">nornir docs book nornir --format svg</text>
<text x="64" y="470" font-family="sans-serif" font-size="19" fill="#24292f">That assembles every .nornir/*.md + repo *.md into the whole-doc book,</text>
<text x="64" y="502" font-family="sans-serif" font-size="19" fill="#24292f">writes page-NNNN.svg + manifest.json, and packs docs/book.znippy</text>
<text x="64" y="534" font-family="sans-serif" font-size="19" fill="#24292f">(pages read O(1) by the lazy viewer). Override with NORNIR_MANUAL_DIR.</text>
</svg>"##,
];
/// The per-user manual data dir: `$HOME/.nornir/manual`. This is where a packed
/// bundle is installed so the manual survives a `cargo install` (which copies
/// ONLY the binary — never sibling data files), and where a bundle is extracted
/// to once.
fn manual_data_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".nornir/manual"))
}
/// Resolve a usable `book-svg` page directory. Tries, in order:
/// 1. `$NORNIR_MANUAL_DIR` (explicit override, pointed at a `book-svg` dir);
/// 2. an already-present page dir — the data dir (`~/.nornir/manual/book-svg`,
/// survives `cargo install`), `<cwd>/docs/book-svg`, or `<exe>/../../docs/book-svg`;
/// 3. a packed bundle (`book-svg.nbook` / `book.nbook`) in those same places —
/// extracted ONCE into `~/.nornir/manual/book-svg`, then reused as a dir.
/// `None` if nothing is found.
fn resolve_book_svg_dir() -> Option<PathBuf> {
if let Some(env) = std::env::var_os("NORNIR_MANUAL_DIR") {
let p = PathBuf::from(env);
if p.is_dir() {
return Some(p);
}
}
// Base locations to look under (in priority order).
let mut bases: Vec<PathBuf> = Vec::new();
if let Some(d) = manual_data_dir() {
bases.push(d);
}
if let Ok(cwd) = std::env::current_dir() {
bases.push(cwd.join("docs"));
}
if let Ok(exe) = std::env::current_exe() {
// target/debug/nornir → repo root is two levels up → its docs/.
if let Some(root) = exe
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
{
bases.push(root.join("docs"));
}
}
// 2. An already-extracted page dir wins (no unpack cost).
for b in &bases {
let dir = b.join("book-svg");
if dir.is_dir() {
return Some(dir);
}
}
// 3. Else a packed bundle → extract once into the data dir, then use it.
for b in &bases {
for name in ["book-svg.nbook", "book.nbook"] {
let bundle = b.join(name);
if bundle.is_file() {
if let Some(dir) = extract_bundle(&bundle) {
return Some(dir);
}
}
}
}
None
}
/// Extract a packed manual bundle into `~/.nornir/manual/book-svg` (once) and
/// return that dir. Uses the airgap `Archive` engine (TarZstd default; znippy
/// under `airgap-znippy`). `None` on any failure (the caller falls through to the
/// "run `nornir docs book --format svg`" hint).
fn extract_bundle(bundle: &Path) -> Option<PathBuf> {
use skidbladnir::archive::{Archive, TarZstd};
let dest = manual_data_dir()?;
std::fs::create_dir_all(&dest).ok()?;
// The bundle packs the `book-svg/` dir, so extract_all lands `book-svg/` under dest.
TarZstd::new().extract_all(bundle, &dest).ok()?;
let dir = dest.join("book-svg");
dir.is_dir().then_some(dir)
}
/// Load the manual from `dir`: read `manifest.json` for the title + table of
/// contents, read every `page-NNNN.svg` (sorted), and build a [`DocView`]
/// (which parses the pages multi-core). The TOC uses the manifest's `toc`
/// (`[{title,page}]`) when present; otherwise it is empty (chapter→page offsets
/// are written by a later `docs book` revision — the viewer still scrolls/zooms
/// every page without them).
fn load_manual(dir: &Path) -> Result<DocView, String> {
let manifest_path = dir.join("manifest.json");
let (title, toc) = match std::fs::read_to_string(&manifest_path) {
Ok(s) => parse_manifest(&s),
Err(_) => ("manual".to_string(), Vec::new()),
};
// Collect page-*.svg in filename order (zero-padded → lexical == numeric).
let mut svg_paths: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| format!("read {}: {e}", dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.extension().and_then(|x| x.to_str()) == Some("svg")
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("page-"))
})
.collect();
svg_paths.sort();
if svg_paths.is_empty() {
return Err(format!("no page-*.svg in {}", dir.display()));
}
// Read the bytes, then build through from_svgs so the parse runs multi-core.
let bytes: Vec<Vec<u8>> = svg_paths
.iter()
.filter_map(|p| std::fs::read(p).ok())
.collect();
Ok(DocView::from_svgs(title, bytes, toc))
}
/// Pull the title + a `(chapter_title, 0-based page)` TOC out of the manifest.
/// Accepts either a future `toc: [{title,page}]` (page-accurate) or the current
/// flat `chapters: [string]` (mapped to no offset → an empty TOC, so the sidebar
/// doesn't mislead by jumping every entry to page 1).
fn parse_manifest(s: &str) -> (String, Vec<(String, usize)>) {
let v: serde_json::Value = match serde_json::from_str(s) {
Ok(v) => v,
Err(_) => return ("manual".to_string(), Vec::new()),
};
let title = v
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("manual")
.to_string();
let toc = v
.get("toc")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| {
let title = e.get("title")?.as_str()?.to_string();
let page = e.get("page")?.as_u64()? as usize;
Some((title, page))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
(title, toc)
}
// ── embedded manual (feature `manual-embed`) ─────────────────────────────────
// The packed archive baked into the binary by build.rs (real `docs/book.znippy`
// if it existed at build time, else a 0-byte placeholder). Travels with `cargo
// install`. `znippy::ZnippyArchive::open` reads a path, so we pluck it out to the
// data dir once rather than seek into rodata.
#[cfg(feature = "manual-embed")]
static EMBEDDED_MANUAL: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/book.znippy"));
/// Write the embedded archive to `~/.nornir/manual/book.znippy` once (skipped
/// when empty, or already present at the same size). After this, the normal
/// znippy resolver finds it on disk. Best-effort.
#[cfg(feature = "manual-embed")]
fn pluck_out_embedded() {
if EMBEDDED_MANUAL.is_empty() {
return; // no manual was baked in — fall back to on-disk resolution
}
let Some(dir) = manual_data_dir() else { return };
let path = dir.join("book.znippy");
// Already plucked out at this exact size? skip the rewrite.
if std::fs::metadata(&path)
.map(|m| m.len() as usize == EMBEDDED_MANUAL.len())
.unwrap_or(false)
{
return;
}
if std::fs::create_dir_all(&dir).is_ok() {
if let Err(e) = std::fs::write(&path, EMBEDDED_MANUAL) {
eprintln!(
"⚠ manual: pluck-out to {} failed (non-fatal): {e}",
path.display()
);
}
}
}
// ── znippy-backed lazy manual (feature `manual-znippy`) ──────────────────────
// A `book.znippy` archive serves each page via an O(1) `extract_file`, so the
// facett-docview lazy viewer decompresses + parses ONLY the visible band — a
// 247 MB manual opens instantly from a ~10 MB archive. The archive read lives
// here (nornir); facett-docview stays storage-agnostic (it only sees `PageSource`).
#[cfg(feature = "manual-znippy")]
use znippy_source::{load_manual_znippy, resolve_book_znippy};
#[cfg(feature = "manual-znippy")]
mod znippy_source {
use super::*;
use facett_docview::PageSource;
use znippy_common::{ZnippyArchive, ZnippyReader};
/// A [`PageSource`] backed by a znippy archive: `page_svg(i)` is an O(1)
/// `extract_file("page-NNNN.svg")` (index lookup → pread → decompress). The
/// archive fd is shared + positioned-read, so the viewer's parallel band
/// fetch is safe.
struct ZnippyManual {
archive: ZnippyArchive,
count: usize,
}
impl PageSource for ZnippyManual {
fn page_count(&self) -> usize {
self.count
}
fn page_svg(&self, i: usize) -> Option<Vec<u8>> {
self.archive
.extract_file(&format!("page-{:04}.svg", i + 1))
.ok()
}
/// Plain-text search layer: the SVG pages are outlined glyph paths with no
/// `<text>`, so search reads the `page-NNNN.txt` sidecar the packer emitted
/// (one per page that has recoverable text). O(1) `extract_file`; a page
/// with no sidecar returns `None` and search honestly skips it.
fn page_text(&self, i: usize) -> Option<String> {
let bytes = self
.archive
.extract_file(&format!("page-{:04}.txt", i + 1))
.ok()?;
let s = String::from_utf8_lossy(&bytes).trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
}
fn is_page(name: &str) -> bool {
let f = name.rsplit('/').next().unwrap_or(name);
f.starts_with("page-") && f.ends_with(".svg")
}
/// Open `book.znippy` → a LAZY [`DocView`] over it. Title + TOC come from the
/// archive's embedded `manifest.json` (best-effort).
pub(super) fn load_manual_znippy(path: &Path) -> Result<DocView, String> {
let archive =
ZnippyArchive::open(path).map_err(|e| format!("open {}: {e:#}", path.display()))?;
let files = archive
.list_files()
.map_err(|e| format!("list {}: {e:#}", path.display()))?;
let count = files.iter().filter(|f| is_page(f)).count();
if count == 0 {
return Err(format!("no page-*.svg in {}", path.display()));
}
let (title, toc) = archive
.extract_file("manifest.json")
.ok()
.map(|b| parse_manifest(&String::from_utf8_lossy(&b)))
.unwrap_or_else(|| ("manual".to_string(), Vec::new()));
Ok(DocView::from_source(
title,
Box::new(ZnippyManual { archive, count }),
toc,
))
}
/// Find a `book.znippy`: `$NORNIR_MANUAL_DIR` (the archive file itself, or a
/// dir holding it), the data dir, `<cwd>/docs`, `<exe>/../../docs`.
pub(super) fn resolve_book_znippy() -> Option<PathBuf> {
let mut cands: Vec<PathBuf> = Vec::new();
if let Some(env) = std::env::var_os("NORNIR_MANUAL_DIR") {
let p = PathBuf::from(env);
if p.is_file() {
return Some(p);
}
cands.push(p.join("book.znippy"));
}
if let Some(d) = manual_data_dir() {
cands.push(d.join("book.znippy"));
}
if let Ok(cwd) = std::env::current_dir() {
cands.push(cwd.join("docs/book.znippy"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(root) = exe
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
{
cands.push(root.join("docs/book.znippy"));
}
}
cands.into_iter().find(|p| p.is_file())
}
}