facett-manual/src/lib.rs
//! 📖 **facett-manual** — the SHARED embedded Manual pane.
//!
//! One documentation pane, consumed by every app that wants its manual a click
//! away inside the GUI (dwarves, korp, …). It renders a set of Markdown documents
//! — a searchable, category-grouped sidebar on the left, the selected document
//! rendered on the right — through [`facett_markdown::render`] (L5: the SAME
//! markdown→egui renderer the ordning editor's Preview lane uses; no second
//! hand-rolled renderer here).
//!
//! ## The embed is the HOST's job, the pane is shared
//! This crate is storage-agnostic: it takes the doc set as **data**
//! ([`ManualDoc`]s). Each host embeds its own `.nornir/*.md` into its binary via
//! `rust_embed` behind a `manual-embed` feature and hands the decoded files to
//! [`ManualPane::from_files`]. So the *mechanism* of baking docs into the binary
//! lives per-app (like nornir's `manual-embed`), while the *pane* is shared — one
//! core, many apps.
//!
//! ## Honest empty state
//! Built with the manual NOT embedded (feature off → the host passes zero docs),
//! the pane says so plainly — "manual not embedded in this build" — it never draws
//! an empty pane as if it were content.
//!
//! ## Why markdown, not nornir's SVG book
//! nornir's `viz::manual_tab` renders a pre-rendered **vector-SVG book**
//! (`facett-docview`, typst → one SVG per page) packed into a `book.znippy`. That
//! path needs the typst/warehouse book pipeline to run first. dwarves' and korp's
//! manuals are their raw `.nornir/*.md` sources (idiot guides, guides, designs),
//! and their warehouses are frequently locked mid-demo — so embedding the markdown
//! sources directly and rendering them live is simpler, warehouse-free, and
//! arguably truer for the idiot guides. This pane is that markdown path.
//!
//! ## Two variants, merged behind one pane (embedded)
//! This pane no longer renders *only* markdown. It also carries a **typeset vector
//! book** and can render EITHER, user-selectable:
//!
//! - **Typeset (SVG)** — the pretty, book-like variant (the **default**), rendered
//! by [`facett_docview`]'s pure-Rust resvg engine over prebuilt SVG pages.
//! - **Markdown** — the continuous, searchable Markdown flow described above.
//!
//! A header **variant toggle** (see [`Variant`]) switches between them. When built
//! via [`ManualPane::embedded`] the whole manual — both the Markdown sources and the
//! typeset SVG book — is compiled into the binary (`include_str!`/`include_bytes!`,
//! see [`mod@embedded`]), so it ships with **no runtime disk dependency** regardless
//! of whether the host wired up a docview book. There is no live Typst compiler
//! in-tree; the typeset pages are prebuilt SVG (rendered, never shelled out to).
/// Compiled-in manual content (Markdown sources + prebuilt typeset SVG book).
pub mod embedded;
use egui::Ui;
use facett_docview::DocView;
use serde::{Deserialize, Serialize};
/// What kind of document a `.nornir/*.md` file is — derived from its filename, used
/// to GROUP and ORDER the sidebar so the beginner ("for idiots") guides surface
/// first (the ones a live demo is driven from), then readmes, guides, designs, …
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DocKind {
/// A `*-for-idiots.md` / `for-idiots.md` beginner guide — surfaced FIRST.
ForIdiots,
/// A top-level `README.md` / `README-full.md`.
Readme,
/// A `*-guide.md` / `guide.md` how-to.
Guide,
/// A `*-design.md` / `design.md` architecture note.
Design,
/// A `*-reasoning.md` / `reasoning.md` rationale.
Reasoning,
/// Anything else (plans, histories, specs, …).
Other,
}
impl DocKind {
/// The sidebar section header this kind groups under.
pub fn header(self) -> &'static str {
match self {
DocKind::ForIdiots => "🔰 For idiots (start here)",
DocKind::Readme => "📄 Readme",
DocKind::Guide => "📘 Guides",
DocKind::Design => "📐 Design",
DocKind::Reasoning => "🧠 Reasoning",
DocKind::Other => "🗂 Other",
}
}
/// Sidebar ordering: beginner guides first, catch-all last.
fn order(self) -> u8 {
match self {
DocKind::ForIdiots => 0,
DocKind::Readme => 1,
DocKind::Guide => 2,
DocKind::Design => 3,
DocKind::Reasoning => 4,
DocKind::Other => 5,
}
}
/// The ordered roster of kinds (the sidebar section order).
fn all() -> [DocKind; 6] {
[
DocKind::ForIdiots,
DocKind::Readme,
DocKind::Guide,
DocKind::Design,
DocKind::Reasoning,
DocKind::Other,
]
}
/// Classify by file stem (lower-cased, no extension).
fn classify(stem: &str) -> DocKind {
let s = stem.to_ascii_lowercase();
if s.contains("for-idiots") || s == "for-dummies" || s.ends_with("-for-dummies") {
DocKind::ForIdiots
} else if s == "readme" || s == "readme-full" {
DocKind::Readme
} else if s == "guide" || s.ends_with("-guide") {
DocKind::Guide
} else if s == "design" || s.ends_with("-design") {
DocKind::Design
} else if s == "reasoning" || s.ends_with("-reasoning") {
DocKind::Reasoning
} else {
DocKind::Other
}
}
}
/// One embedded manual document.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ManualDoc {
/// Stable id — the file stem (e.g. `infra-for-idiots`). The sidebar key + the
/// `state_json` selection id + the deep-link target.
pub slug: String,
/// Display title — the document's first `# ` heading if it has one, else the
/// slug prettified (`infra-for-idiots` → `Infra for idiots`).
pub title: String,
/// Which sidebar group this doc lives under.
pub kind: DocKind,
/// The raw markdown body.
pub body: String,
}
impl ManualDoc {
/// Build a doc from a file NAME (e.g. `infra-for-idiots.md`, or a path like
/// `.nornir/infra-for-idiots.md`) and its UTF-8 body. Derives the slug from the
/// stem, the kind from the stem, and the title from the first `# ` heading (or
/// the prettified stem).
pub fn from_file(name: &str, body: impl Into<String>) -> Self {
let body = body.into();
let file = name.rsplit('/').next().unwrap_or(name);
let stem = file.strip_suffix(".md").or_else(|| file.strip_suffix(".markdown")).unwrap_or(file);
let kind = DocKind::classify(stem);
let title = first_heading(&body).unwrap_or_else(|| prettify(stem));
Self { slug: stem.to_string(), title, kind, body }
}
/// Whether a filename is a **template / scaffold** the manual should SKIP —
/// `.nornir/_TEMPLATE-*.md`, `_TEMPLATES.md`, and dotfile-underscore scaffolds.
/// (Called by hosts filtering the embedded set; kept here so every host skips
/// the same non-content files.)
pub fn is_template(name: &str) -> bool {
let file = name.rsplit('/').next().unwrap_or(name);
file.starts_with('_')
}
}
/// The first `# ` heading's text in `md`, if any (trimmed).
fn first_heading(md: &str) -> Option<String> {
for line in md.lines() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("# ") {
let h = rest.trim();
if !h.is_empty() {
return Some(h.to_string());
}
}
}
None
}
/// `infra-for-idiots` → `Infra for idiots` (first letter upper, `-`/`_` → space).
fn prettify(stem: &str) -> String {
let spaced: String = stem.chars().map(|c| if c == '-' || c == '_' { ' ' } else { c }).collect();
let mut chars = spaced.chars();
match chars.next() {
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
None => spaced,
}
}
/// How the reading area presents the doc set.
///
/// - [`ViewMode::Paged`] — the classic behavior: ONE doc at a time, chosen from the
/// sidebar (least surprise; the default).
/// - [`ViewMode::Continuous`] — every (search-passing) doc concatenated top-to-bottom
/// in one vertically-scrolling view, read like a book. The sidebar still works: a
/// click SCROLLS to that doc's section rather than switching pages.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ViewMode {
/// One doc at a time (the sidebar picks it). The default — least surprise.
Paged,
/// All docs in one scrolling flow; the sidebar scrolls to a section.
Continuous,
}
impl ViewMode {
/// The `state_json` token — `"paged"` / `"continuous"` (robot-assertable).
pub fn as_str(self) -> &'static str {
match self {
ViewMode::Paged => "paged",
ViewMode::Continuous => "continuous",
}
}
}
/// Which **rendering variant** the Manual pane shows — the user-selectable choice
/// behind the header toggle.
///
/// - [`Variant::Typeset`] — the pretty, book-like **typeset SVG** book, rendered by
/// [`facett_docview`] (resvg). The **default** whenever a typeset book is present.
/// - [`Variant::Markdown`] — the continuous, searchable **Markdown** flow (the
/// classic Manual pane). The only variant when no typeset book was supplied.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Variant {
/// The typeset vector (SVG) book — the default when available.
Typeset,
/// The continuous Markdown flow.
Markdown,
}
impl Variant {
/// The `state_json` token — `"typeset"` / `"markdown"` (robot-assertable).
pub fn as_str(self) -> &'static str {
match self {
Variant::Typeset => "typeset",
Variant::Markdown => "markdown",
}
}
}
/// 📖 The shared **Manual pane**: a searchable, category-grouped Markdown viewer over
/// an embedded doc set. A [`facett_core::Facet`], so any host mounts it like any pane.
pub struct ManualPane {
/// The app whose manual this is (`"dwarves"`, `"korp"`) — shown in the header
/// and the honest empty state.
app: String,
/// The embedded docs, already sorted (kind order, then title).
docs: Vec<ManualDoc>,
/// The selected doc index into `docs` (clamped; ignored when `docs` is empty).
/// In [`ViewMode::Paged`] this is the shown doc; in [`ViewMode::Continuous`] it is
/// the "current"/last-navigated-to section (the sidebar-scroll target).
selected: usize,
/// The live search query — filters the sidebar to docs whose title OR body
/// contains it (case-insensitive).
query: String,
/// Paged vs continuous reading. See [`ViewMode`]. (Applies to the Markdown variant.)
view_mode: ViewMode,
/// In [`ViewMode::Continuous`], set when the sidebar (or a search jump) asks the
/// view to scroll to `selected`'s section; consumed on the next `ui()` frame.
pending_scroll: bool,
/// Which rendering variant is shown (Typeset SVG vs Markdown). See [`Variant`].
/// Defaults to [`Variant::Typeset`] when a typeset book is present, else Markdown.
variant: Variant,
/// The typeset (SVG) book, when one was supplied — the [`Variant::Typeset`]
/// renderer. `None` for a Markdown-only pane (e.g. [`ManualPane::from_files`]),
/// in which case the variant toggle is hidden and the pane is Markdown-only.
book: Option<DocView>,
}
impl ManualPane {
/// Build the pane from a decoded doc set. Templates should already be filtered
/// by the host (via [`ManualDoc::is_template`]); the docs are sorted here into
/// the stable sidebar order (kind, then title). An EMPTY set is the honest
/// "manual not embedded in this build" state.
pub fn new(app: impl Into<String>, docs: Vec<ManualDoc>) -> Self {
Self::assemble(app, docs, None)
}
/// Build the **merged** pane from the crate's OWN compiled-in content (see
/// [`mod@embedded`]): the embedded Markdown sources *and* the embedded typeset
/// SVG book. Everything loads via `include_str!`/`include_bytes!` — **no
/// filesystem access** — so the manual ships in the binary regardless of docview.
///
/// The default variant is [`Variant::Typeset`] (a typeset book is present); the
/// header toggle switches to [`Variant::Markdown`]. This is the constructor a
/// host uses to mount the full, self-contained facett Manual.
pub fn embedded(app: impl Into<String>) -> Self {
let app = app.into();
let docs = embedded::MARKDOWN_DOCS
.iter()
.filter(|(name, _)| !ManualDoc::is_template(name))
.map(|(name, body)| ManualDoc::from_file(name, *body))
.collect();
let book = Self::embedded_book(&app);
Self::assemble(app, docs, book)
}
/// Build the typeset [`DocView`] from the embedded SVG book, or `None` if the
/// crate carries no prebuilt pages. Parsed via facett-docview's pure-Rust resvg
/// path (no shelling to a Typst binary).
fn embedded_book(app: &str) -> Option<DocView> {
if embedded::SVG_PAGES.is_empty() {
return None;
}
let toc: Vec<(String, usize)> =
embedded::TYPESET_TOC.iter().map(|(t, p)| (t.to_string(), *p)).collect();
Some(DocView::from_svgs(
format!("{app} manual"),
embedded::SVG_PAGES.iter().copied(),
toc,
))
}
/// Assemble a pane from a (to-be-sorted) doc set and an optional typeset book.
/// The variant defaults to [`Variant::Typeset`] when a book is present (the
/// pretty default), else [`Variant::Markdown`].
fn assemble(app: impl Into<String>, mut docs: Vec<ManualDoc>, book: Option<DocView>) -> Self {
docs.sort_by(|a, b| {
a.kind
.order()
.cmp(&b.kind.order())
.then_with(|| a.title.to_ascii_lowercase().cmp(&b.title.to_ascii_lowercase()))
});
// Default to the pretty Typeset (SVG) variant whenever a book is present;
// a Markdown-only pane (no book) can only be Markdown.
let variant = if book.is_some() { Variant::Typeset } else { Variant::Markdown };
Self {
app: app.into(),
docs,
selected: 0,
query: String::new(),
// Default to Paged: least surprise for existing hosts (korp/dwarves) and a
// preserved regression baseline. The Continuous toggle is right there in the
// header for anyone who wants to read the manual straight through.
view_mode: ViewMode::Paged,
pending_scroll: false,
variant,
book,
}
}
/// Build the pane from `(name, body)` file pairs — the shape a host's
/// `rust_embed` iterator yields. Template/scaffold files ([`ManualDoc::is_template`])
/// are skipped; the rest become [`ManualDoc`]s.
pub fn from_files<'a, I, S>(app: impl Into<String>, files: I) -> Self
where
I: IntoIterator<Item = (&'a str, S)>,
S: Into<String>,
{
let docs = files
.into_iter()
.filter(|(name, _)| !ManualDoc::is_template(name))
.map(|(name, body)| ManualDoc::from_file(name, body))
.collect();
Self::new(app, docs)
}
/// Build the **merged, dual-variant** pane from a HOST's own data: its decoded
/// Markdown `(name, body)` files *and* a prebuilt **typeset SVG book** (one SVG
/// blob per page, in reading order, plus a chapter-level `toc` of
/// `(title, 0-based page index)`).
///
/// This is the storage-agnostic twin of [`ManualPane::embedded`]: where `embedded`
/// reads *this crate's* compiled-in sample content, this constructor takes the
/// host's real doc set — so an app (dwarves, korp, …) can ship the **typeset SVG
/// of ITS OWN `.nornir` docs** as the default variant, with the SAME docs as the
/// Markdown toggle, without this shared crate carrying any per-app content. The
/// SVG pages are typically `include_bytes!`-bundled by the host and rendered
/// offline by [`facett_docview`]'s pure-Rust resvg engine (no Typst at runtime).
///
/// Template/scaffold files ([`ManualDoc::is_template`]) are filtered from the
/// Markdown set. When `svg_pages` is empty the pane degrades gracefully to
/// Markdown-only (identical to [`ManualPane::from_files`]); otherwise it defaults
/// to [`Variant::Typeset`] (the pretty book) with the header toggle offering
/// Markdown. Additive: [`from_files`](Self::from_files) and existing hosts (korp)
/// are unaffected.
pub fn from_files_with_svgs<'a, I, S, P, B>(
app: impl Into<String>,
files: I,
svg_pages: P,
toc: Vec<(String, usize)>,
) -> Self
where
I: IntoIterator<Item = (&'a str, S)>,
S: Into<String>,
P: IntoIterator<Item = B>,
B: AsRef<[u8]> + Send,
{
let app = app.into();
let docs = files
.into_iter()
.filter(|(name, _)| !ManualDoc::is_template(name))
.map(|(name, body)| ManualDoc::from_file(name, body))
.collect();
let svgs: Vec<B> = svg_pages.into_iter().collect();
let book = if svgs.is_empty() {
None
} else {
Some(DocView::from_svgs(format!("{app} manual"), svgs, toc))
};
Self::assemble(app, docs, book)
}
/// The honest EMPTY pane — the manual was NOT embedded in this build (the host's
/// `manual-embed` feature is off). Renders a plain "not embedded" hint, never a
/// blank pane masquerading as content.
pub fn not_embedded(app: impl Into<String>) -> Self {
Self::new(app, Vec::new())
}
/// Whether any manual content is embedded (Markdown docs OR a typeset book) —
/// i.e. the manual is really available in this build.
pub fn is_embedded(&self) -> bool {
!self.docs.is_empty() || self.book.is_some()
}
/// The current rendering variant (Typeset SVG vs Markdown).
pub fn variant(&self) -> Variant {
self.variant
}
/// Whether a typeset (SVG) book is present — i.e. the variant toggle is offered
/// and [`Variant::Typeset`] is selectable.
pub fn has_typeset(&self) -> bool {
self.book.is_some()
}
/// Switch the rendering variant. Selecting [`Variant::Typeset`] with no book
/// present is a no-op (there is nothing typeset to show).
pub fn set_variant(&mut self, variant: Variant) {
if variant == Variant::Typeset && self.book.is_none() {
return;
}
self.variant = variant;
}
/// The number of pages in the embedded typeset book (0 when none).
pub fn typeset_page_count(&self) -> usize {
self.book.as_ref().map(|b| b.page_count()).unwrap_or(0)
}
/// The number of embedded docs.
pub fn doc_count(&self) -> usize {
self.docs.len()
}
/// The slug of the currently-selected doc, or `""` when empty.
pub fn selected_slug(&self) -> &str {
self.docs.get(self.selected).map(|d| d.slug.as_str()).unwrap_or("")
}
/// Select a doc by slug (returns whether it matched). In [`ViewMode::Continuous`]
/// this also arms a scroll-to-section so the seam behaves like a sidebar click.
pub fn select(&mut self, slug: &str) -> bool {
if let Some(i) = self.docs.iter().position(|d| d.slug == slug) {
self.selected = i;
if self.view_mode == ViewMode::Continuous {
self.pending_scroll = true;
}
true
} else {
false
}
}
/// The current view mode.
pub fn view_mode(&self) -> ViewMode {
self.view_mode
}
/// Switch view mode. Entering [`ViewMode::Continuous`] arms a scroll to the current
/// section so the reader lands where they were, not at the top.
pub fn set_view_mode(&mut self, mode: ViewMode) {
if mode == ViewMode::Continuous && self.view_mode != ViewMode::Continuous {
self.pending_scroll = true;
}
self.view_mode = mode;
}
/// Whether `doc` passes the current search query (title or body match).
fn matches(&self, doc: &ManualDoc) -> bool {
if self.query.trim().is_empty() {
return true;
}
let q = self.query.to_ascii_lowercase();
doc.title.to_ascii_lowercase().contains(&q) || doc.body.to_ascii_lowercase().contains(&q)
}
// ── Facet contract ──────────────────────────────────────────────────────────
pub fn title(&self) -> &str {
"Manual"
}
pub fn state_json(&self) -> serde_json::Value {
// The search-passing docs, in sidebar order — the sidebar's current list AND,
// in Continuous mode, exactly the sections the reading area renders top-to-bottom.
let hits: Vec<String> =
self.docs.iter().filter(|d| self.matches(d)).map(|d| d.slug.clone()).collect();
// The sections physically present in the reading area this frame:
// Paged → just the one selected doc,
// Continuous → every hit (so a robot can assert >1 section is present).
let sections: Vec<String> = match self.view_mode {
ViewMode::Paged => {
self.docs.get(self.selected).map(|d| vec![d.slug.clone()]).unwrap_or_default()
}
ViewMode::Continuous => hits.clone(),
};
serde_json::json!({
"app": self.app,
"embedded": self.is_embedded(),
"doc_count": self.doc_count(),
// Typeset (SVG, default) | Markdown — the robot-assertable rendering variant.
"variant": self.variant.as_str(),
// Whether a typeset book is present (the variant toggle is offered).
"has_typeset": self.has_typeset(),
// Pages in the embedded typeset book (0 when none).
"typeset_page_count": self.typeset_page_count(),
"selected": self.selected_slug(),
"query": self.query,
// Paged (default) | Continuous — the Markdown variant's reading mode.
"view_mode": self.view_mode.as_str(),
// The current sidebar-scroll target section (== `selected` in continuous).
"scroll_target": self.selected_slug(),
"docs": self.docs.iter().map(|d| d.slug.clone()).collect::<Vec<_>>(),
// The filtered (search) hit slugs — what the sidebar currently lists.
"hits": hits,
// The sections actually rendered in the reading area this frame (see above).
"sections": sections,
})
}
/// Render the pane: an optional **variant toggle** (shown only when a typeset
/// book is present) then the selected variant — [`Variant::Typeset`] (the SVG
/// book, via [`facett_docview`]) or [`Variant::Markdown`] (the classic flow).
pub fn ui(&mut self, ui: &mut Ui) {
// Honest empty state — no manual content (neither Markdown nor a book) baked in.
if self.docs.is_empty() && self.book.is_none() {
ui.add_space(24.0);
ui.vertical_centered(|ui| {
ui.label(egui::RichText::new("📖 Manual not embedded in this build").strong());
ui.add_space(6.0);
ui.label(
egui::RichText::new(format!(
"Rebuild {} with the manual embedded to bake the docs into the binary.",
self.app
))
.weak(),
);
});
return;
}
// Variant toggle — only when both variants are available (a book is present).
if self.book.is_some() {
self.variant_toggle(ui);
ui.separator();
}
match self.variant {
Variant::Typeset => self.reading_typeset(ui),
Variant::Markdown => self.markdown_ui(ui),
}
}
/// The header **variant toggle**: Typeset (SVG) ↔ Markdown. Only drawn when a
/// typeset book exists; `set_variant` guards the no-book case regardless.
fn variant_toggle(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.weak("variant:");
let mut v = self.variant;
let typ = ui
.selectable_value(&mut v, Variant::Typeset, "🖨 Typeset (SVG)")
.on_hover_text("the pretty, typeset vector book (default)");
let md = ui
.selectable_value(&mut v, Variant::Markdown, "📝 Markdown")
.on_hover_text("the same manual as continuous, searchable Markdown");
if typ.clicked() || md.clicked() {
self.set_variant(v);
}
});
}
/// The **Typeset (SVG)** variant — delegate the whole reader (sidebar, zoom,
/// continuous/paged, search) to the embedded [`facett_docview`] book.
fn reading_typeset(&mut self, ui: &mut Ui) {
// Bring the docview Facet trait into scope so `book.ui(..)` resolves (it is
// the SAME `facett_core::Facet` this pane implements, re-exported by docview).
use facett_docview::Facet as _;
if let Some(book) = self.book.as_mut() {
book.ui(ui);
}
}
/// The Markdown-variant reading UI (search box + Paged/Continuous toggle +
/// category sidebar + rendered doc) — the classic Manual pane, now one of the
/// two variants [`ManualPane::ui`] dispatches to.
fn markdown_ui(&mut self, ui: &mut Ui) {
// Honest empty state — no Markdown docs baked into this build.
if self.docs.is_empty() {
ui.add_space(24.0);
ui.vertical_centered(|ui| {
ui.label(egui::RichText::new("📖 Manual not embedded in this build").strong());
ui.add_space(6.0);
ui.label(
egui::RichText::new(format!(
"Rebuild {} with `--features manual-embed` to bake the .nornir docs \
(idiot guides, guides, designs) into the binary.",
self.app
))
.weak(),
);
});
return;
}
// Search box + hit count (left) and the view-mode toggle (right), across the top.
ui.horizontal(|ui| {
ui.label("🔎");
let hits = self.docs.iter().filter(|d| self.matches(d)).count();
ui.add(
egui::TextEdit::singleline(&mut self.query)
.desired_width(240.0)
.hint_text("search the manual…"),
);
if !self.query.trim().is_empty() {
if ui.button("✕").on_hover_text("clear search").clicked() {
self.query.clear();
}
ui.weak(format!("{hits} / {} docs", self.docs.len()));
} else {
ui.weak(format!("{} docs", self.docs.len()));
}
// View-mode toggle, pinned to the right: Paged (one doc) ↔ Continuous
// (all docs in one scroll). `set_view_mode` arms the scroll-to-section on
// entering Continuous so the reader lands on the doc they were on.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut mode = self.view_mode;
// right_to_left: add in reverse visual order (Continuous shows rightmost).
let cont = ui
.selectable_value(&mut mode, ViewMode::Continuous, "📜 Continuous")
.on_hover_text("read every doc top-to-bottom in one scroll");
let paged = ui
.selectable_value(&mut mode, ViewMode::Paged, "📄 Paged")
.on_hover_text("one doc at a time");
if cont.clicked() || paged.clicked() {
self.set_view_mode(mode);
}
ui.weak("view:");
});
});
ui.separator();
// If the search hid the selected doc, jump to the first hit so the reading
// pane always shows a doc that is actually in the list.
let selected_visible = self
.docs
.get(self.selected)
.map(|d| self.matches(d))
.unwrap_or(false);
if !selected_visible && let Some(i) = self.docs.iter().position(|d| self.matches(d)) {
self.selected = i;
}
// Two panes: the grouped sidebar (left) | the rendered doc (right).
egui::Panel::left(ui.id().with("facett_manual_sidebar"))
.resizable(true)
.default_size(240.0)
.show(ui, |ui| {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let query_active = !self.query.trim().is_empty();
let mut any_shown = false;
for kind in DocKind::all() {
// Collect this section's visible (search-passing) docs.
let indices: Vec<usize> = self
.docs
.iter()
.enumerate()
.filter(|(_, d)| d.kind == kind && self.matches(d))
.map(|(i, _)| i)
.collect();
if indices.is_empty() {
continue;
}
any_shown = true;
ui.add_space(4.0);
ui.label(egui::RichText::new(kind.header()).small().strong());
for i in indices {
let selected = i == self.selected;
let title = self.docs[i].title.clone();
if ui.selectable_label(selected, &title).clicked() {
self.selected = i;
// Continuous: a click SCROLLS to that section (arm it);
// Paged: a click switches the shown doc (nothing to scroll).
if self.view_mode == ViewMode::Continuous {
self.pending_scroll = true;
}
}
}
let _ = query_active;
}
if !any_shown {
ui.add_space(8.0);
ui.weak("no docs match your search");
}
});
});
// The reading pane, rendered with the SHARED markdown renderer
// (facett-markdown's Preview-lane path). L5: no second renderer.
egui::CentralPanel::default().show(ui, |ui| match self.view_mode {
ViewMode::Paged => self.reading_paged(ui),
ViewMode::Continuous => self.reading_continuous(ui),
});
}
/// Paged reading area — the single selected doc (unchanged classic behavior).
fn reading_paged(&mut self, ui: &mut Ui) {
if let Some(doc) = self.docs.get(self.selected) {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.label(egui::RichText::new(&doc.title).heading());
ui.weak(format!("{} · {}.md", doc.kind.header(), doc.slug));
ui.separator();
facett_markdown::render(ui, &doc.body);
});
}
}
/// Continuous reading area — EVERY search-passing doc concatenated top-to-bottom in
/// one `ScrollArea`, in the sidebar's order, each opened by a clear section heading
/// (its title) + a divider. A sidebar click / search jump arms `pending_scroll`,
/// which here scrolls the matching section's heading to the top (`scroll_to_me`).
fn reading_continuous(&mut self, ui: &mut Ui) {
// The visible sections, in sidebar order. Snapshot indices so the borrow of
// `self.docs` doesn't collide with `self.pending_scroll` writes below.
let visible: Vec<usize> =
self.docs.iter().enumerate().filter(|(_, d)| self.matches(d)).map(|(i, _)| i).collect();
if visible.is_empty() {
ui.add_space(8.0);
ui.weak("no docs match your search");
return;
}
// Sticky current-section banner — a restrained "you are reading" indicator.
if let Some(doc) = self.docs.get(self.selected) {
ui.label(
egui::RichText::new(format!("📖 {}", doc.title)).small().strong(),
);
}
ui.separator();
let want_scroll = self.pending_scroll;
let target = self.selected;
let mut did_scroll = false;
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let last = *visible.last().unwrap();
for i in visible {
let doc = &self.docs[i];
// Section heading — the anchor the sidebar scrolls to. Give it a stable
// id via the doc slug so it is a distinct, queryable widget per section.
let heading = ui.push_id(("facett_manual_section", &doc.slug), |ui| {
let r = ui.label(egui::RichText::new(&doc.title).heading());
ui.weak(format!("{} · {}.md", doc.kind.header(), doc.slug));
r
});
if want_scroll && i == target {
heading.inner.scroll_to_me(Some(egui::Align::TOP));
did_scroll = true;
}
ui.separator();
facett_markdown::render(ui, &doc.body);
if i != last {
ui.add_space(18.0);
ui.separator();
ui.add_space(6.0);
}
}
});
// Consume the scroll request once issued (or if the target vanished from view).
if want_scroll && (did_scroll || !self.docs.get(target).map(|d| self.matches(d)).unwrap_or(false)) {
self.pending_scroll = false;
}
}
pub fn update_json(&mut self, msg_json: &str) {
// Robot / deep-link drive seam:
// {"variant":"typeset"|"markdown"} — switch the rendering variant (toggle twin);
// {"select":"<slug>"} — open (Paged) / scroll to (Continuous) a doc by slug;
// {"search":"<text>"} — set the search query (headless twin of the box);
// {"view_mode":"paged"|"continuous"} — switch reading mode (twin of the toggle).
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
// {"variant":"typeset"|"markdown"} — switch the rendering variant (twin of
// the header toggle). Typeset is ignored when no book is present.
if let Some(variant) = v.get("variant").and_then(|s| s.as_str()) {
match variant {
"typeset" => self.set_variant(Variant::Typeset),
"markdown" => self.set_variant(Variant::Markdown),
_ => {}
}
}
if let Some(m) = v.get("view_mode").and_then(|s| s.as_str()) {
match m {
"paged" => self.set_view_mode(ViewMode::Paged),
"continuous" => self.set_view_mode(ViewMode::Continuous),
_ => {}
}
}
if let Some(slug) = v.get("select").and_then(|s| s.as_str()) {
self.select(slug);
}
if let Some(q) = v.get("search").and_then(|s| s.as_str()) {
self.query = q.to_string();
}
}
}
impl facett_core::Facet for ManualPane {
fn title(&self) -> &str {
ManualPane::title(self)
}
fn ui(&mut self, ui: &mut Ui) {
ManualPane::ui(self, ui)
}
fn state_json(&self) -> serde_json::Value {
ManualPane::state_json(self)
}
fn update_json(&mut self, msg_json: &str) {
ManualPane::update_json(self, msg_json)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_by_stem() {
assert_eq!(DocKind::classify("infra-for-idiots"), DocKind::ForIdiots);
assert_eq!(DocKind::classify("for-idiots"), DocKind::ForIdiots);
assert_eq!(DocKind::classify("readme"), DocKind::Readme);
assert_eq!(DocKind::classify("pipeline-guide"), DocKind::Guide);
assert_eq!(DocKind::classify("graph-design"), DocKind::Design);
assert_eq!(DocKind::classify("collectors-reasoning"), DocKind::Reasoning);
assert_eq!(DocKind::classify("korp-worklist"), DocKind::Other);
}
#[test]
fn from_file_derives_title_and_slug() {
let d = ManualDoc::from_file(
".nornir/infra-for-idiots.md",
"# Infra for idiots\n\nSpark 4.1 → Iceberg → FalkorDB.\n",
);
assert_eq!(d.slug, "infra-for-idiots");
assert_eq!(d.title, "Infra for idiots");
assert_eq!(d.kind, DocKind::ForIdiots);
}
#[test]
fn from_file_prettifies_when_no_heading() {
let d = ManualDoc::from_file("three-modes.md", "no heading here\n");
assert_eq!(d.title, "Three modes");
}
#[test]
fn templates_are_skipped() {
assert!(ManualDoc::is_template("_TEMPLATE-guide.md"));
assert!(ManualDoc::is_template(".nornir/_TEMPLATES.md"));
assert!(!ManualDoc::is_template("infra-for-idiots.md"));
}
#[test]
fn from_files_filters_templates_and_sorts_idiots_first() {
let pane = ManualPane::from_files(
"korp",
vec![
("_TEMPLATE-guide.md", "# T\n"),
("graph-design.md", "# Graph design\n"),
("infra-for-idiots.md", "# Infra for idiots\n"),
],
);
assert_eq!(pane.doc_count(), 2, "template skipped");
// ForIdiots sorts first, so it is the default selection.
assert_eq!(pane.selected_slug(), "infra-for-idiots");
assert_eq!(pane.docs[0].slug, "infra-for-idiots");
assert_eq!(pane.docs[1].slug, "graph-design");
}
#[test]
fn not_embedded_is_honest_empty() {
let pane = ManualPane::not_embedded("dwarves");
assert!(!pane.is_embedded());
assert_eq!(pane.doc_count(), 0);
assert_eq!(pane.state_json()["embedded"], serde_json::json!(false));
}
#[test]
fn search_and_select_over_json_seam() {
let mut pane = ManualPane::from_files(
"korp",
vec![
("infra-for-idiots.md", "# Infra\n\nSpark and Iceberg and FalkorDB.\n"),
("graph-design.md", "# Graph\n\nedges and nodes.\n"),
],
);
pane.update_json(r#"{"select":"graph-design"}"#);
assert_eq!(pane.selected_slug(), "graph-design");
pane.update_json(r#"{"search":"falkordb"}"#);
let hits = pane.state_json()["hits"].as_array().unwrap().clone();
assert_eq!(hits, vec![serde_json::json!("infra-for-idiots")]);
}
#[test]
fn selected_slug_after_select() {
let mut pane = ManualPane::from_files(
"korp",
vec![("a-guide.md", "# A\n"), ("b-design.md", "# B\n")],
);
assert!(pane.select("b-design"));
assert_eq!(pane.selected_slug(), "b-design");
assert!(!pane.select("nope"));
}
// ── merged-variant coverage (embedded content, default variant, toggle) ──────
#[test]
fn embedded_default_variant_is_typeset_svg() {
// The merged, self-contained Manual defaults to the pretty Typeset (SVG)
// variant — the owner's requirement: DEFAULT = typst-SVG.
let pane = ManualPane::embedded("facett");
assert_eq!(pane.variant(), Variant::Typeset, "default variant is typeset (SVG)");
assert!(pane.has_typeset(), "a typeset book is present");
let st = pane.state_json();
assert_eq!(st["variant"], serde_json::json!("typeset"), "state_json reports typeset by default");
assert_eq!(st["has_typeset"], serde_json::json!(true));
}
#[test]
fn variant_toggle_switches_typeset_to_markdown() {
// The toggle (and its headless twin) flips Typeset ⇄ Markdown, defaulting typeset.
let mut pane = ManualPane::embedded("facett");
assert_eq!(pane.variant(), Variant::Typeset);
// Twin of clicking the "Markdown" toggle.
pane.update_json(r#"{"variant":"markdown"}"#);
assert_eq!(pane.variant(), Variant::Markdown, "toggle switched to markdown");
assert_eq!(pane.state_json()["variant"], serde_json::json!("markdown"));
// …and back to typeset.
pane.set_variant(Variant::Typeset);
assert_eq!(pane.variant(), Variant::Typeset, "toggle switched back to typeset");
}
#[test]
fn manual_content_is_embedded_no_filesystem() {
// The whole manual is compiled in via include_str!/include_bytes! — this test
// touches ZERO filesystem paths: the content comes straight from the binary.
assert!(!embedded::MARKDOWN_DOCS.is_empty(), "markdown sources are embedded");
assert!(!embedded::SVG_PAGES.is_empty(), "typeset SVG pages are embedded");
for (_, body) in embedded::MARKDOWN_DOCS {
assert!(!body.trim().is_empty(), "embedded markdown body is non-empty");
}
for page in embedded::SVG_PAGES {
assert!(page.windows(4).any(|w| w == b"<svg"), "embedded page is real SVG");
}
// Built from the embedded content alone, the pane is genuinely available:
// markdown docs AND a parsed typeset book, no disk access anywhere.
let pane = ManualPane::embedded("facett");
assert!(pane.is_embedded(), "embedded pane is available");
assert!(pane.doc_count() >= 1, "embedded markdown docs loaded");
assert_eq!(
pane.typeset_page_count(),
embedded::SVG_PAGES.len(),
"every embedded SVG page parsed into the typeset book"
);
}
#[test]
fn from_files_with_svgs_defaults_to_typeset_over_host_docs() {
// A minimal but well-formed SVG page (parseable by facett-docview's resvg).
const PAGE: &[u8] =
br#"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="160"></svg>"#;
// The host hands in its OWN markdown docs AND its OWN typeset SVG book.
let pane = ManualPane::from_files_with_svgs(
"dwarves",
vec![
("_TEMPLATE-guide.md", "# T\n"),
("dwarves-for-idiots.md", "# Dwarves for idiots\n\nreal content.\n"),
("release-design.md", "# Release design\n\nmore real content.\n"),
],
vec![PAGE, PAGE],
vec![("Dwarves for idiots".to_string(), 0)],
);
// Default is the pretty Typeset (SVG) variant, backed by the host's book.
assert_eq!(pane.variant(), Variant::Typeset, "default is typeset when a book is present");
assert!(pane.has_typeset(), "the host's typeset book is present");
assert_eq!(pane.typeset_page_count(), 2, "both SVG pages parsed into the book");
// The Markdown variant carries the host's REAL docs (template filtered out).
assert_eq!(pane.doc_count(), 2, "template skipped, both real docs kept");
assert_eq!(pane.selected_slug(), "dwarves-for-idiots", "for-idiots sorts first");
let st = pane.state_json();
assert_eq!(st["variant"], serde_json::json!("typeset"));
assert_eq!(st["has_typeset"], serde_json::json!(true));
let slugs = st["docs"].as_array().unwrap().clone();
assert!(slugs.iter().any(|v| v == "dwarves-for-idiots"), "real doc slug present");
}
#[test]
fn from_files_with_svgs_empty_book_is_markdown_only() {
// No SVG pages supplied → graceful degrade to a Markdown-only pane (exactly
// `from_files`): no book, no toggle, Markdown variant.
let empty: Vec<&[u8]> = Vec::new();
let pane = ManualPane::from_files_with_svgs(
"dwarves",
vec![("a-guide.md", "# A\n")],
empty,
Vec::new(),
);
assert!(!pane.has_typeset(), "no book when no SVG pages supplied");
assert_eq!(pane.variant(), Variant::Markdown, "markdown-only without a book");
assert_eq!(pane.doc_count(), 1);
}
#[test]
fn markdown_only_pane_has_no_typeset_and_stays_markdown() {
// A pane built from Markdown files alone (the classic host path) has no book,
// so it is Markdown-only — the variant toggle is not offered and Typeset is a
// no-op. This preserves the existing korp/dwarves behavior exactly.
let mut pane = ManualPane::from_files("korp", vec![("a-guide.md", "# A\n")]);
assert!(!pane.has_typeset());
assert_eq!(pane.variant(), Variant::Markdown, "markdown-only pane is markdown");
pane.set_variant(Variant::Typeset); // no book → no-op
assert_eq!(pane.variant(), Variant::Markdown, "cannot switch to typeset without a book");
}
}