use std::path::{Path, PathBuf};
use eframe::egui::{self};
use facett_docview::{DocView, Facet};
pub struct ManualTab {
view: Option<DocView>,
loaded: bool,
error: Option<String>,
dir: Option<PathBuf>,
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()
}
pub fn reload(&mut self) {
self.loaded = false;
self.view = None;
self.error = None;
self.builtin = false;
}
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;
#[cfg(feature = "manual-embed")]
pluck_out_embedded();
#[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),
Err(e) => {
eprintln!("⚠ manual: {e}; using the built-in fallback book");
self.load_builtin();
}
}
}
None => self.load_builtin(),
}
}
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();
#[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(),
"builtin": self.builtin,
"error": self.error,
"doc": self.view.as_ref().map(|v| v.state_json()),
})
}
}
static BUILTIN_PAGES: [&str; 3] = [
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>"##,
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">⚒ Dwarfs (build + CI forge) · 📦 Holger · 📖 Manual · 🤖 Claude</text>
</svg>"##,
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>"##,
];
fn manual_data_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".nornir/manual"))
}
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);
}
}
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() {
if let Some(root) = exe.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) {
bases.push(root.join("docs"));
}
}
for b in &bases {
let dir = b.join("book-svg");
if dir.is_dir() {
return Some(dir);
}
}
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
}
fn extract_bundle(bundle: &Path) -> Option<PathBuf> {
use skidbladnir::archive::{Archive, TarZstd};
let dest = manual_data_dir()?;
std::fs::create_dir_all(&dest).ok()?;
TarZstd::new().extract_all(bundle, &dest).ok()?;
let dir = dest.join("book-svg");
dir.is_dir().then_some(dir)
}
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()),
};
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()));
}
let bytes: Vec<Vec<u8>> = svg_paths.iter().filter_map(|p| std::fs::read(p).ok()).collect();
Ok(DocView::from_svgs(title, bytes, toc))
}
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)
}
#[cfg(feature = "manual-embed")]
static EMBEDDED_MANUAL: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/book.znippy"));
#[cfg(feature = "manual-embed")]
fn pluck_out_embedded() {
if EMBEDDED_MANUAL.is_empty() {
return; }
let Some(dir) = manual_data_dir() else { return };
let path = dir.join("book.znippy");
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());
}
}
}
#[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};
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()
}
}
fn is_page(name: &str) -> bool {
let f = name.rsplit('/').next().unwrap_or(name);
f.starts_with("page-") && f.ends_with(".svg")
}
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))
}
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())
}
}